• Разбираемся в часовыми поясами. Инструкция по безопасной работе со временем

    Во всех проектах, где мне приходилось работать (включая текущий), были проблемы с часовыми поясами. В этой статье я обобщу полученный опыт о работе со временем. Разберемся раз и навсегда как обращаться с ним правильно.

    Зоны и их преобразования – достаточно тяжелая для изучения тема. Это не проходят в институтах, об этом мало статей на популярных ресурсах. Разработчики учатся работать со временем методом проб и ошибок.

    В одном из прошлых проектов в нашей команде не было никого, кто бы мог внятно донести лучшие практики работы со временем. Коллективное незнание было компенсировано харизмой и желанием писать много кода.

    Мы воздвигли модуль полный костылей и багов, которые ловили в проде. Провалы становились очевидны когда исправлять их было уже затратно. Скорее всего, в вашем проекте дела обстоят так же.

    Не устану повторять случай с коллегой, который работал в лондонском стартапе. Лондон это такой город, от которого отсчитываются часовые пояса, потому что через него проходит нулевой меридиан. Иначе говоря, локальное и общемировое времена в Лондоне одинаковы (не считая обозначения зоны, конечно).

    Пока разработка шла в Лондоне, никто и не думал ни про какие зоны, и код писали без их учета. Разработчик с зоной +3 столкнулся с тем, что не проходит ни один тест, потому что время прибито гвоздями без учета пояса. В итоге коллега проработал у них год сидя с переведенными часами. Было проще поменять зону локально, чем исправить код.

    Забавно, что бизнес той фирмы был связан с расписанием общественного транспорта, где время имеет решающую роль. Это снова поднимает вопросы о том, кто и как пишет повседневное ПО.

    В нашем проекте фронтендер решил, что он умнее системы и что нужно помочь клиенту перевести локальное время в UTC. И тем самым заложил в систему трудноуловимый баг. При отправке объекта Date через Ajax первый автоматически приводится к общемировому времени. Затем срабатывал обработчик коллеги, который тоже корректировал пояс. Получается, что мы дважды вычитали локальное время на клиенте и тем самым портили любую дату с сервера.

    Теперь представьте, что будет, если показать не то время на сайте продажи авиабилетов. Или прислать смс-напоминание с ошибкой в приведении часовых зон. Это будет стоить колоссальных денег и нервов.

    Я заметил, что откровенно плохо обстоит со временем у фронтендеров. Это связано с тем, что время – скорее серверная часть приложения, и к тому же требует некоторой подготовки и мышления. Чаще всего ошибки случались на стороне клиентской части.

    Главное правило: никогда не пишите свой код для операций над временными зонами. Все современные библиотеки уже имеют этот функционал из коробки. Обертки над временными операциями должны быть минимальными. Если модуль разрастается без контроля, вы явно пишете говнокод. Остановитесь, пока весь проект не стал зависеть от кривых классов или функций.

    Хорошей практикой будет избегать преобразований на местах внутри бизнес-логики. Лучше создать отдельный модуль и наполнять его вспомогательным кодом. Логично, что если где-то выяснится, что нужно было отнимать интервал, а не прибавлять, то исправить одну функцию будет проще, чем сканировать весь проект.

    Для работы со временем лучше сразу брать хорошую стороннюю библиотеку. Время настолько сложная вещь, что редкий язык может похвастаться качественной коробочной реализацией. В Джаваскрипте объект Date убог. В Джаве класс java.util.Date был объявлен deprecated уже с версии 1.1, то есть почти сразу. Родной datetime в Питоне приемлем только для базовых задач.

    Не нужно думать, что “мне только оберточку написать”, зато без зависимостей. Время – важно. Пусть будут зависимости, но меньше багов.

    Рассмотрим основные положения о часовых поясах. Если оставить в стороне политические и географические причины, то в силу сферической природы мира разные люди могут наблюдать одно и то же положение Солнца на небосводе в разные моменты времени. Назовем это локальным временем. Последнее работает только в пределах того меридиана, дальше начинается неопределенность.

    (Конечно, часовые пояса идут не идеально ровно как полоски на арбузе. Еще недавно в России меняли временные зоны – объединяли и делили соседние. Так, стараниями Медведева в моей родной Чите зимой в 16 часов было уже темно как ночью. Но для простоты будем считать, что пояса распределены равномерно.)

    Поэтому фраза “созвонимся в три часа” в общемировом масштаба не значит ничего. По чьему времени в три часа? По Москве? Нью-Йорку? Очевидно, нужна особая метка чтобы обозначить, откуда это время получено. Тогда путем простых вычислений можно перевести время из одной метки в другую. Эта метка и называется временной зоной.

    Обозначение у нее может быть разное, например смещение в часах (+03:00), имя части света и города через косую черту (Europe/Berlin) или одна из аббревиатур (CET, Центральное Европейское время), но это уже тонкости стандартов кадирования. Важно то, что теперь время привязано к конкретной местности и может быть преобразовано в универсальное время UTC.

    UTC – универсальное координированное время – это современный стандарт отсчета, пришедший на смену GMT. Не будем сейчас разбирать его особенности, достаточно сказать, что на шкале поясов он играет роль нуля: именно от него отсчитываются отрицательные (западные) и положительные (восточные) зоны.

    Например, я живу в Воронеже, мой пояс третий по счету на восток, поэтому я могу написать в резюме “when calling, please consider my +3 UTC timezone” или просто “my TZ is +3”. Тогда заказчик из Лондона, у которого нулевой пояс, добавит к своему времени 3 часа и получит мое локальное время.

    Я подвожу вас к мысли, что в приложениях недостаточно знать только время, нужно знать еще и зону, иначе одно и то же время может быть истолковано по-разному. Так мы переходим к технической части статьи.

    Ваш сервер и база данных отвечают за хранение дат. Современные БД (здесь и далее – PostgreSQL) предоставляют два типа полей: это timestamp with time zone и timestamp without time zone, то есть с зоной или без. Если в первое поле записать дату с зоной, то БД сконвертирует ее в UTC, потому что так удобней. А при выводе этой даты в утилитах она будет представлена в локальном времени того пользователя, кто сейчас ее просматривает.

    Рассмотрим пример:

    create table foo (
      ts_z timestamp with time zone,
      ts timestamp without time zone
    );
    
    CREATE TABLE
    
    insert into foo (ts_z) values
      ('2017-12-13T10:00:00-05:00'::timestamp with time zone);
    
    INSERT 0 1
    
    select * from foo;
    

    Результат:

              ts_z          | ts
    ------------------------+----
     2017-12-13 18:00:00+03 |
    (1 row)
    

    Я создал таблицу с двумя полями: время с зоной и без. Потом записал в поле с временной зоной время с часовым поясом Нью-Йорка. В базе оно будет храниться как 2017-12-13 15:00:00 UTC. Но при выводе я увижу время в консоли в моем локальном времени. Все верно, мой пояс +3, дата была приведена верно.

    А в поле ts нужно писать только время по UTC, иначе будет ошибка:

    insert into foo (ts) values
      ('2017-12-13T10:00:00-05:00'::timestamp with time zone);
    
    INSERT 0 1
    
    select * from foo;
    
              ts_z          |         ts
    ------------------------+---------------------
     2017-12-13 18:00:00+03 |
                            | 2017-12-13 18:00:00
    (2 rows)
    

    Видим, что во второе поле записалось не то, что нужно: правильное время по UTC должно быть 15 часов, а не 18. Вот как нужно было сделать:

    insert into foo (ts) values
      ('2017-12-13T10:00:00-05:00'::timestamp with time zone AT TIME ZONE 'UTC');
    
    INSERT 0 1
    
    select * from foo;
    
              ts_z          |         ts
    ------------------------+---------------------
     2017-12-13 18:00:00+03 |
                            | 2017-12-13 18:00:00
                            | 2017-12-13 15:00:00
    (3 rows)
    

    Эта и другие ошибки будут рассмотрены ниже.

    В различных библиотеках и ORM при выборке подобных дат они восстанавливаются в объекты языка с заполненной часовой зоной (поля tz, zone, tzinfo и тд).

    Вариант дат без зон тоже имеет право на жизнь, и на мой взгляд он проще. В команде вы просто соглашаетесь, что все даты хранятся как локальное время по UTC. Все библиотеки имеют функции и методы для работы с таким временем, например, datetime.utcnow() в Питоне, moment.utc() в JS и тд.

    Клиент и сервер должны обмениваться друг с другом только датами в UTC. Универсальное время переводится в локальное на стороне клиента исходя из локального смещения относительно нулевого меридиана. Эту величину можно определить методом .getTimezoneOffset() объекта Date на клиенте.

    При передаче дат клиенту вы кодируете их в ISO-формат с зоной. Если зона UTC, она обозначается буквой Z. Таким образом, в текстовом представлении время, отправляемое клиенту будет выглядеть так:

    "2017-12-12T14:12:23.502Z"
    

    Слева направо: год, месяц, день, разделитель, часы, минуты, секунды, микросекунды, индикатор нулевой зоны UTC.

    При кодировании даты без зоны некоторые библиотеки могут не добавить Z на конце, что может запутать клиента. Обязательно учтите это: добавьте в модуль функцию, которая будет добавлять Z к результирующей строке и пользуйтесь только этой функцией (см. выше о единой кодовой базе для работы со временем).

    На этом этапе нельзя не заметить, насколько убог формат JSON: он не может передавать даты и не расширяется.

    В обратную сторону с клиента на сервер даты передаются в таком же формате: ISO-строка в UTC с Z на конце в качестве зоны.

    Рассмотрим, что делать клиенту с полученной строкой. Легче всего восстановить ее в объект библиотекой moment.js:

    moment("2017-12-12T14:20:05.849Z").format()
    "2017-12-12T17:20:05+03:00"
    

    Видим, что все правильно: мой пояс +3, часы были увеличены на эту разницу, на конце индикатор +03:00.

    В сторону: сейчас кто-то встанет и начнет заливать, что moment.js уже не популярен, и что горячие парни пишут какую-то новую либу на замену ей. Мой совет – шлите лесом таких советчиков. Нам нужно проверенное решение, а не модная поделка.

    В каком формате показывать дату – зависит от вас. В каждой стране свои форматы, определяющие порядок следования составляющих (день, месяц или месяц, день), разделителей (точка, косая черта, дефис), написание месяцев (Dec, December). Важно понять, что успешный вывод даты зависит от того, была ли правильно она восстановлена из строки на этапе ранее.

    Стоит запомнить: если показ даты требует от вас каких-то махинаций вроде прибавления или вычитания часов, минут или что-то в этом роде – срочно остановитесь, вы гарантированно что-то делаете неправильно. Или должно быть четкое понимание, почему нельзя сделать по-другому.

    При передаче JSON-тела на сервер объект Date не нужно приводить в ISO-строку вручную. Для этого существует метод .toJSON(), который автоматически будет вызван при кодировании внешнего словаря. Например:

    var d = new Date();
    Tue Dec 12 2017 18:40:48 GMT+0300 (MSK)
    
    JSON.stringify({foo: 42, bar: d})
    "{"foo":42,"bar":"2017-12-12T15:40:48.573Z"}"
    

    Рассмотрим основные ошибки, связанные с обработкой времени и как с ними бороться. Начнем с сервера.

    • Тип поля указан timestamp with time zone, но вы передаете объект времени без зоны.

    В этом случае сервер подставит текущую зону, то есть ту, в которой расположен сервер. Если пользователь расположен далеко, например, на другом континенте, это может вызвать значительные неудобства. Скажем, пользователь из Нью-Йорка (-5 UTC) передал на сервер время "2017-12-12T14:20:00Z". Сервер расположен в Швейцарии (+1 UTC). По какой-то причине зона была отброшена, и в базу вместо "2017-12-12T23:20:00 UTC" (исходное плюс 5 часов) записалось "2017-12-12T13:20:00 UTC" (исходное минус 1 час). Ошибка в 10 часов! Представьте, что это напоминания о поездке или рассылка смс.

    • Тип поля timestamp without time zone, но в базу пишется локальное время сервера.

    Убедитесь, что для получения текущего времени вы используете функцию с utc в ее имени, например, datetime.utcnow() вместо datetime.now() и аналогично в других языках. В PostgreSQL аналогично: почти для всех вызовов временных функций нужно указывать зону UTC. Сравните:

    > select current_timestamp;
           now
    -------------------------------
     2017-12-12 19:03:07.683706+03
    
    
    > select current_timestamp at time zone 'UTC';
         timezone
    --------------------------
     2017-12-12 16:04:21.7177
    

    Перевести зону из одной в другую на уровне БД можно следующим оператором:

    select
      '2017-12-13T10:00:00-05:00'::timestamp with time zone AT TIME ZONE 'UTC'
      as right_time;
    
         right_time
    ---------------------
     2017-12-13 15:00:00
    

    Теперь клиент.

    • С сервера приходят UTC-даты в ISO, но без метки Z, например "2017-12-12T14:20:05".

    Попытка отобразить такую дату с помощью moment.js, как мы делали выше, приведет к ошибке:

    moment("2017-12-12T14:20:05").format()
    "2017-12-12T14:20:05+03:00"
    

    Вариантов тут два: либо приклеить к строке со временем нужную зону, либо явно указать библиотеке, что это время в UTC:

    moment.utc("2017-12-12T14:20:05").local().format()
    "2017-12-12T17:20:05+03:00"
    

    Метод .utc() парсит строку с учетом того, что результат будет время по UTC. Метод .local() преобразует его к локальному времени на основании смещения, которое берется из браузера. Теперь все правильно.

    • С сервера приходит его локальное время.

    Это беда, но лечится явным указанием зоны. Например, вы в Москве, а сервер в Нью-Йорке. Он отдает локальное время "2017-12-12T14:20:05". Нью-Йорк в минус пятом часовом поясе. Приклеим зону к строке и распарсим:

    moment("2017-12-12T14:20:05" + "-05:00").format()
    "2017-12-12T22:20:05+03:00"
    

    Значит, это 22:20 вечера по Москве. Проверим устно: если от НЙ до нулевого меридиана 5 часов, а потом еще 3 часа до Москвы, то в сумме 8 часов. 14 + 8 = 22 часа местного времени.

    • Сервер отдает время в виде “unix epoch”.

    Это значит в количестве секунд со дня рождения ОС Юникс – 1 января 1970 года. Такое время всегда представлено в UTC. Отобразить его пользователю можно так:

    moment.unix(1513107997).format()
    "2017-12-12T22:46:37+03:00"
    

    На мой взгляд, хранение времени в виде числа секунд имеет ряд недостатков. Во-первых, оно совершенно нечитаемо. По набору цифр невозможно определить даже год с точностью до десятка. Во-вторых, это не оптимально. Современные БД хранят даты в таком виде, чтобы было удобно обращаться к их составным частям. Напротив, хранение в дат в секундах требует постоянной конвертации.

    Обобщу вышесказанное.

    Научиться работать со временем не сложно. Главное – не оправдывать лень и не дожидаться момента, когда съедут все даты вечером в пятницу. Время – важно.

    Воздержитесь от собственных поделок для работы с часовыми поясами. Оставляйте операции со временем на откуп проверенным библиотекам. Выносите вспомогательные функции в особый модуль.

    Храните даты на сервере либо с временной зоной UTC, либо без нее, но подразумевая это.

    Клиент и сервер должны пересылать даты только в UTC с нулевой зоной Z.

    Локальное время выводится на клиенте из UTC относительно локального смещения пользователя.

    При кодировании дат в JSON последний сам позаботится часовом поясе. Вы не должны что-то прибавлять или вычитать.

    Маленький бонус: рассмотрим как быть, если формировать локальное время нужно не на клиенте, а на сервере. До сих пор я вел разговор в том ключе, что у нас Single page application и все рендериться на клиенте. Однако, чисто серверные приложения никуда не делись, и такая задача тоже может возникнуть.

    Представим, что даты хранятся в UTC, но вот пришел HTTP-запрос от какого-то пользователя, и нужно показать даты в его локальном формате. Как быть?

    Пока у нас нет никаких данных о пользователе, можно, используя его IP, через сторонний сервис или библиотеку с локальной базой адресов на борту хотя бы примерно определить его страну или даже город. По этом данным станет легко вывести временной пояс.

    Но страна вовсе не означает время. Так, пользователь Воронежа может сидеть через прокси или Тор с выходной нодой где-то в Индии, но при это его локальное время не имеет отношения к этой стране.

    Узнать локальное смещение можно через специальный метод объекта Date:

    (new Date()).getTimezoneOffset()
    -180
    

    Оно выражено в минутах и имеет знак противоположный часовому поясу. Если у меня зона +3, то смещение будет -180 минут.

    С помощью скрипта это значение можно положить в куки на стороне клиента, и тогда все последующие запросы к серверу будут сообщать о настоящем часовом поясе пользователя.

    Чтобы привести время UTC в локальное на сервере, достаточно вычесть из первого смещение в минутах. При этом нужно держать в уме знак: вычитание отрицательных минут означает их прибавление, что нам и нужно.

    Рассмотрим код на Питоне:

    from datetime import datetime, timedelta
    
    utc = datetime.utcnow()
    loc = utc - timedelta(minutes=-180)
    
    utc # datetime.datetime(2017, 12, 13, 13, 57, 18, 108606)
    loc # datetime.datetime(2017, 12, 13, 16, 57, 18, 108606)
    

    Локальное время на 3 часа больше, теперь эту дату можно показывать пользователю.

    Это был пример явной манипуляции с часовыми поясами. Выше я писал, что этого следует избегать. Однако, здесь явно присутствует понимание того, что мы делаем. Простой вычет будет эффективней и проще использования навороченной библиотеки с часовыми поясами.

    Ну вот и все, думаю, это было не сложно. Я не люблю просить о репостах и никогда не делал этого, но на этот раз сделаю исключение. Прошу показать эту статью тем, кто связан с веб-разработкой, особенно фронтендом. Возможно, хоть где-то удастся избежать ошибок вроде тех, что я описал в самом начале. Может, хоть немного облегчатся чьи-то будни.

    Награда для тех, кто прочел до конца:

  • Пробный стрим про Кложу и Биткоин

    Вчера стримил Кложу. Писал библиотеку-обертку для популярных Биткоин-кошельков: Bitcoin.Core и Electrum. Неплохо пролучилось для первого раза, на мой взгляд. Надо попробовать стрим на английском. Код на Гитхабе.

  • Государство -- враг

    На одной из встреч Рефакторинга наш юрист Александр Мазалов хорошо сказал, что государство это машина, которая умеет только бить кувалдой по голове. С остальными функциями оно справляется плохо.

    Кто-то наверняка встанет и начнет разливаться про социальную защищенность и налоги. Но вот что, послушайте историю с обычным штрафом. Проверим, справедлив ли тезис или нет.

    Значит, выписали мне обычный штраф за превышение – не увидел знак 40 км, проехал на 60. Оплатил в мобильном приложении Сбера. Через месяц падает письмо с Госуслуг: все тот же неоплаченный штраф. Поскольку искать что-то в истории Сбербанка это слезы и боль, решил, что, может быть, не нажал на последнюю кнопку в форме или как-то иначе затупил. Оплатил через Тинькова, на этот раз проверил, что деньги списались.

    Через месяц приходит письмо, что будем тебя судить за неоплаченный штраф. Удаляю. Еще через месяц – смс из Сбера, что со счета арестованы 800 рублей.

    Я звоню, барышня говорит да, пришел запрос от приставов на арест денег за неуплаченный штраф. Почему же вы, черти, не защитили клиента? Вы же могли проверить по коду или реквизитам, что штраф погашен? И сами разрулить?

    Нет, говорят, такой регламент определен государством. Нам сказали списать, мы по закону обязаны, а за что – не наше дело, разруливай сами

    Ладно, звоню приставам. Там колл-центр. Напрямую дозвониться нельзя. По телефону ничего решить невозможно, только лично и с паспортом. Контора у черта на рогах, ехать по адовым пробкам. Можно почтой, но все документы должны быть заверены у нотариуса, отвечать будут месяц (плюс еще месяц на доставку нашей почтой).

    Короче, оценил я затраты времени и сил и плюнул на это дело. Пусть подавятся.

    Через месяц снова арестовывают 800 рублей. Тут уж я решил разорвать колесо Сансары и поехал.

    В конторе убогость: бывший подъезд панельной пятиэтажки, толпа, стульев нет, стоит школьная парта. Даже сеть не ловит.

    Показываю приставу бумаги и смс. Думал, что он начнет возмущаться, что не может быть, все по закону. А он с невозмутимым лицом говорит: да, бывает. Я по ошибке списал и в отпуск ушел, а задачу не закрыл. Пришла коллега и снова списала.

    Конечно, денег он мне не вернул, но дал бумагу. Езжай с ней в ГИБДД. А вы-то сами, говорю, не в состоянии это утрясти? Нет. Ну и чтоб вас не томить: там тоже стоял, ждал приема, потом мент полчаса тупил в комп и дал последнюю, ультимейт-бумагу, которую нужно отправить в какой-то другой центр ГИБДД.

    А сами-то, бляди, вы не можете отправить? Нет.

    И в общем, через месяц после того как бумагу доставил, приходит заказное письмо: да, вернем вам деньги. И еще через месяц падает нотификация со Сбера, что деньги поступили. Аминь.

    Началась эта петрушка ранним летом, закончилась поздней осенью. Просто из интереса решил проверить, к чему все придет.

    Главный вывод: государство не заинтересовано в восстановлении справедливости. Битье по голове работает как часы, все остальное – нет. Если бы это была обычная жалобная заметка, коих в сети тысячи, то можно было бы и закончить. Но я хочу обратить внимание на дизайн всей системы.

    Смотрите, все связи устроены так, что нигде не предусматривается обратное распространение сигнала. Во всех случаях одно ведомство передавало сигнал другому, но как только нужно наоборот – вот тебе бумага, вези на другой конец города. Вы же, менты и банкиры, для нужд внутреннего документооборота не колесите по городу на личном транспорте? У вас же почта, курьеры, приватные сети. Но в обратную сторону ничего не работает.

    Регламент отношений между ведомостями проектировал не один человек, а целая группа. Ни у кого не возникла мысль, как автоматически разрулить ошибку. В приставах же не роботы сидят, а люди, они ошибаются. Должен быть отдельный регламент по обработке ошибок. Нельзя сплавлять их на конечного человека.

    Все это вместе пример плохого дизайна. Это распределенная система, в которой совершенно не учтены ошибки и порядок их разрешения.

    К сожалению, именно так мы и пишем программы. Не тот параметр – упало. В словаре нет ключа – упало, и пусть десять запущенных тредов тоже упадут. Сервер ответил 404 – исключение, программа завершилась. Приложение, как попка-дурак, каждый раз спрашивает одно и то же. Все делай сам: нажимай, логируй, проверяй.

    Никого не волнует, что не ты должен этим заниматься.

  • Бесы

    Отписываюсь от авторов, которые злоупотребляют темой “как же меня бесит”. Особенно когда материал касается людей: не так говорят, не так делают, пользуются не теми программами. Хватит, надоело.

    Жалобу на неудобный дизайн или кривой код я воспринимаю как рабочую заметку. Это ошибка в работе, и ее можно исправить: завести таску в джире, повесить на человека, затрекать время. Изжить проблему со временем.

    Но жалобы на людей или насмешки по поводу одежды или внешности не идут ни в какие рамки. Такие заметки ни что иное как провокация. Можно долго врать, что ты просто высказываешь мнение, и никто не обязан его слушать, но врешь в первую очередь самому себе.

    Люди физически не могут молчать, особенно когда их мнение резко расходится с высказанным. Идея о том, что все вокруг соберутся такие продвинутые, молча выслушают и пройдут мимо – бред. Подобного рода заметки – банальный вброс и срач.

    Представим, я напишу пост о том, как меня бесят инвалиды. И каждому, кто напишет в личку, буду отвечать: я ведь не говорил, что инвалиды плохие, просто они меня бесят. Это вы лезете в мою жизнь. Технически все нормально, но это поведение, так сказать, не очень порядочного человека.

    Зачем же ты провоцируешь окружающих? Кому от этого поста стало легче? Число бесящих тебя людей сократилось? Они поняли свои ошибки, исправились?

    Нет же. Каждый, кто хоть немного работал с людьми знает, что человек меняется годами и десятилетиями. Ты собрал трафик, вызвал дискуссию, но на самом деле банально поссорил кого-то.

    Еще и пишешь продолжение, что я Д’Артаньян, а вы ничего не поняли. Не профессионально и не по мужски.

    И заодно заканчивайте жаловаться на плохое меню в кафе, ожидание в ресторане, отсутствие парковки, грубую продавщицу, платную подписку в программе, низкую зарплату, сложные вопросы на собеседованиях, дурака-начальника, рост тарифов и курс доллара.

    Все это останется даже когда вас не станет. А вот как жить так, чтобы ничего из вышеперечисленного на вас не влияло – вопрос гораздо интересней.

  • Ссылки на выходные #30

  • Thoughts on UI tests

    Generally, I’m a big fan of writing test. They are great: once you add them, you cannot break logic silently anymore. The time you’ve spent on writing tests pays off completely in that manner you won’t need to fix production on Friday’s night. Tests cannot guarantee your code is free from errors completely, though. But having properly designed tests greatly reduces the number of potential mistakes.

    For a long time, I’ve been developing only server-side tests whereas the UI behavior was tested manually. That has been worked until the moment when we understood we break the UI too often. That’s how the question about having UI tests has appeared.

    TL;DR: I didn’t anticipate maintaining client-side tests (also known as integration test) would be so costly. Yet I managed to get stable UI tests, it took long time of debugging, reading the source code of webdrivers and Google/StackOverflow surfing. Here are some random thoughts on the subject.

    UI tests are complex

    The first issue you might face when dealing with UI tests (and it has never been written clearly in any documentation or manual) is they are unstable and flaking by their nature. In technical terms, they are fragile and difficult to maintain. Testing UI involves lots of technologies and protocols. Under the hood, there is an entire stack of nested calls. Let’s consider a typical PHP/JS-driven web-application with Selenium-based tests. Now follow the testing pipeline.

    Before you run a test, you should launch the application server (probably your REST backend) and Selenium server. Then, once you’ve started a test, it sends request to Selenium to start a new browser session. Java spawns a new process of webdriver – a special program that helps to automate browsers. The driver tries to discover a browser installed on your machine. If everything is alright, the browser process starts. Just a note: you have not done anything important yet, but there are four processes are running already (the app, Selenium, webdriver, browser).

    Now then, every time you call for something like

    >>> driver.find_element_by_id("my_button")
    

    , it turns into a stack with the steps look like approximately the following:

    1. your PHP/Python/whatever code calls Selenium server.
    2. Selenium remaps the incoming data and calls webdriver server.
    3. The webdriver reads JSON body and sends corresponding binary commands to the browser via socket channel.
    4. The browser performs those actions with some delay. There is no guarantee about timings, the numbers depend on your OS, number of cores, amount of open tabs and so on.
    5. If everything is alright, the data flows in the opposite way: from the browser to the webdriver, to Selenium and finally to the app.

    Remember, it was only a simplest API call. More complicated API expand into series of calls and success only when all of sub-calls managed to finish successfully.

    Versioning hell

    What exactly may occur during the process? Well, everything.

    The most obvious thing is the more software you are trying to work together the more unstable the system becomes. That might not be a problem when all the components are under your control. Say, if it was a micro-service architecture with each node developed by your people. But if fact, they all come from different companies with different points on the subject. Your testing library is written by some enthusiasts. Selenium is the second group. The browser is the third one and they do not listen to anybody, that’s for sure. Developers who have brought the webdriver are the fourth group.

    Now imagine that every product has its own version, release schedule and roadmap. And the version 4.2.1 of this requires at least version 3.2.1 of that. In the release 5.4 the feature X doesn’t work, you should use 5.5-beta instead. And more many of that. These bits of information are never shared in the documentation, but only in comments on Github.

    It reminds me The Planet Parade sometimes when the system performs only if some special version’s numbers are set together. Otherwise, it falls apart.

    They run for too long

    Another issue with integration tests that you might not be aware about is their long execution time. It becomes new to me after long period of maintaining server tests that used to pass in a minute or two. Even having a small set of integration tests, it would take much more time. If we care about running each tests independently without affecting them each other, you need to turn the app to its initial state. That involves resetting the database, logging out, wiping session, preparing local files and so forth. It takes sensible amount time all together. Be prepared your CI pipeline becomes more time-consuming.

    Referencing a missing element

    What I’d like to highlight more in that topic is a strange error named “element stale”. It will definitely rich you when dealing with single page application (aka SPA). It is so common that the official Selenium site has a dedicated page and an exception class named after if. The sad thing, those text is quite stingy on describing what could be the reason of getting such an error. And more important, how to prevent it appears. Hopefully, after long debugging sessions, I’ve got answers to both of those questions.

    Inside browser, every DOM element has its own unique machinery-wise identifier. There is no a single standard of representing it. Depending on browser, you might get either 0.950889431100737-1 or c1ee22f1-b96e-5245-bd85-9e56e1781cbd for the same button. But it does not matter. What really does is it’s quite easy to refer an element that has been removed from the DOM tree when the app page has been re-rendered.

    That’s the root of the problem: such modern frameworks as React, Vue or whatever else continuously update the page components inserting new HTML nodes. Even when you have a component with a button with unique “id” attribute and it has been re-rendered, an new DOM element instance will be created. From the browser’s prospective, it will the another node, completely different to the previous one.

    Each modern testing framework has high-level functions to operate on page elements without knowing their IDs. Consider Python examples:

    >>> driver.click_on_element("my_button")
    

    In fact, this API call expands into at leas two low-level Webdriver API calls, namely:

    1. Try to discover an element ID by the search term.
    2. Try to click on that element.

    Looks well, but here is what takes place under the hood. You’ve got a long ID of a link or a button, that’s great. But in a millisecond before you clicked on it, the UI has been re-rendered. The reason of that is not so important: maybe, some event has been triggered, or a user hovered some element when moving mouse to it. Or maybe, the framework’s internal algorithm just decided somehow the content should be updated. That means, the original button element you’ve got the ID for was detached from the DOM thee and moved into a temporary array or any other kind of data structure to wait before it is wiped completely from memory. Instead of it, a new DOM node that looks exactly the same took those place. But willing to click on the button, use send the outdated ID. There is no sense in clicking on such kind of elements that are almost dead. The browser returns corresponding error message that expands into Selenium exception.

    I wish that paragraph was on those page. It would save me a week maybe or even more.

    A good solution would be to implement some kind of transactions in your code. Let every complex API be implemented with try/catch clause with some sensible strategy to re-call it in a case of failure, say a number of times or by timeouts.

    High Hopes

    OK, let’s have some final thoughts on the subject, some kind of summary. Testing UI is really far from the server-side ideal world. They are unstable, difficult to develop and maintain. There a lots of undocumented traps you may suffer from during the journey. But still, there are definitely some good things happening nowadays.

    First, the official Webdriver protocol is developing actively. This protocol is subject to substitute the outdated Selenium JSONWire protocol. It has become a part of W3C so I hope it will be developed so forth.

    More Selenium-free libraries appear on Github: Nightwatch, Capybara, Puppeteer… That is great because having the only one monopolist in such specific area stops the progress.

    But there are lots of things to be done. What I’d like to see at first place is some kind of cookbook with common advise and good practices on how to test UI without suffering. Those bits should not depend on specific language or library although there could be platform-specific sections. I have never met such kind of a cookbook across the Internet; please correct me if there are any of them.

    When writing a library for browser automation, it would be great to not only cover the low-level calls but also design an abstract layer of high-level API to make it easy to use by those who are not familiar with all the issues I mentioned above. Say, a high-level function (or method if we talk on OOP terms) that clicks on a button should take into account the case when the element is stale. From my prospectives, such a functional language powered with immutable data structures and homoiconicity as Clojure (or any Lisp-family one) would be a great choice.

    Ok, let’s finish for now. Thank you for listening out. Next time, I’ll say more on technical side of testing code.

  • Mozilla makes me crazy

    What I’d like to share with you is my suffering from dealing with browsers. I’m an author of a Clojure library that automates browsers. Briefly, the library doesn’t depend on Selenium since it implements the Webdriver protocol with pure Clojure. It brings flexibility and freedom in the way you’d like to build your API.

    At the same time, everybody who is willing to tame several browsers at once is doomed to open the Pandora box. There is the official standard I thought, so definitely everybody will follow it. A naive laddie I was.

    I don’t know if there is some kind of curse or black magic, but sometimes developers cannot just implement the standard as it dictates the things should be done. For example, it says the server should accept a value field. Developers write code that takes text instead. When I see it, I have strange feeling of being completely stunned without any understanding of what’s going on here.

    But what exactly I wanted to discuss is how does Mozilla develop their software.

    Briefly, they break everything apart moving further. And I don’t know how to deal with it. Maybe they enjoy coding in Rust so much that their mission to ship good software has faded away.

    I know I’m not a Mozilla engineer and would never be capable of joining them, but still. As a user of their products, namely Geckodriver and Webdriver utilities, I consider myself being right when criticizing them.

    With each next release of Geckodriver, it behaves worth and worth. When I first started developing the library (a bit less then 1 year ago), it was 0.13 version available and it worked fine. I wrote a bunch of unit tests that all passed. Nowadays, the latest version is 0.19 and it just doesn’t work.

    Yes, you heard correct, it does not even respond to any API call:

    ~/Downloads> ./geckodriver-0.19.0
    1510326430666 geckodriver INFO geckodriver 0.19.0
    1510326430680 geckodriver INFO Listening on 127.0.0.1:4444
    
    curl -X POST -d '{"desiredCapabilities": {}}' "http://127.0.0.1:4444/session"
    

    Calling curl utility throws a long stack trace saying something about sandbox restrictions without any word on how to fix that or at least where to look for help.

    Here is a related issue that confirms I’m not the first one who faced it. It’s closed! “As I said, this isn’t a problem with geckodriver”, one of developers says. OK, but I’m still curious about why does the version 0.13 work fine whereas switching on 0.19 leads to failure? Proof:

    ~/Downloads> ./geckodriver-0.13.0 --version
    geckodriver 0.13.0
    
    ~/Downloads> ./geckodriver-0.13.0
    1510327215587 geckodriver INFO Listening on 127.0.0.1:4444
    
    curl -X POST -d '{"desiredCapabilities": {}}' "http://127.0.0.1:4444/session"
    >>> {"sessionId":"e744bbdd-1b3f-9249-827f-02204bbc81c8","value":{"acceptInsecureCerts":...
    

    Ok, let’s decrease the version a bit. I downloaded them moving backwards in time. Again, 0.18 still does not work. We need to go deeper. The previous one numbered with 0.17 starts well, but suddenly, I cannot fetch a new session with my library, it just returns nil value. That’s strange.

    Going down to 0.15. Don’t know why, but I cannot fill any input field, the API fails with a strange error saying I didn’t pass the “text” field. Hm, I clearly remember that the API accepts “value” field. That’s what the W3C standard says. Geckodriver 0.13 follows it, but not the 0.15 release! After fetching the official Mozilla repo and searching in its history for a while, I found this (truncated):

    diff -r 225d1faf513b -r b429bf0078c4 testing/webdriver/src/command.rs
    --- a/testing/webdriver/src/command.rs	Tue Mar 07 18:50:56 2017 +0000
    +++ b/testing/webdriver/src/command.rs	Wed Mar 15 13:54:19 2017 +0000
    @@ -752,7 +752,7 @@
    
     #[derive(PartialEq)]
     pub struct SendKeysParameters {
    -    pub value: Vec<char>
    +    pub text: String
     }
    
     impl Parameters for SendKeysParameters {
    @@ -760,26 +760,14 @@
             let data = try_opt!(body.as_object(),
                                 ErrorStatus::InvalidArgument,
    ...
    -            Ok(chars[0])
    -        }).collect::<Result<Vec<_>, _>>());
    +        let text = try_opt!(try_opt!(data.get("text"),
    ...
    +            text: text.into()
             })
         }
     }
    @@ -787,10 +775,7 @@
     impl ToJson for SendKeysParameters {
         fn to_json(&self) -> Json {
             let mut data = BTreeMap::new();
    -        let value_string: Vec<String> = self.value.iter().map(|x| {
    -            x.to_string()
    -        }).collect();
    -        data.insert("value".to_string(), value_string.to_json());
    +        data.insert("value".to_string(), self.text.to_json());
             Json::Object(data)
         }
     }
    
    

    Hold on, what was the purpose to change that field? Everything was working, right? Who asked for that? Did the standard change? No, it’s still the same! Why have you guys just changed the API? You could easily rewrite the code without renaming “value” field into “text”.

    Listen, I’ve already got three different versions of those API for Chrome, Firefox and Safari. In addition to that, your force me to switch on version inside Firefox branch turning my code into if/else hell.

    From your perspectives, what’s the difference in processing either “value” or “text” field? But for me, you’ve broken my software! It doesn’t work anymore.

    I could not find a diff that would proof the fact of moving “sessionId” field because the repo’s history is pretty complicated. But it’s easy to check that. The version 0.17 returns:

    {
      "sessionId":"4decec3e-e564-b349-bb41-b27b5f307e01",
      "value":{
        "acceptInsecureCerts":false,
        "browserName":"firefox",
        "browserVersion":"52.0
        ...
    

    whereas 0.13 returns:

    {"value":{
      "sessionId":"d0dc41f8-9c17-934e-be2b-708c72f0fb9c",
      "capabilities":{
        "acceptInsecureCerts":false,
        "browserName":"firefox",
        ...
    

    Look, they just wrapped the whole data into “value” subfield shifting it one level below. Again, what was the purpose of doing this? Did you think of those users who have already been using your code for some time?

    Thanks to those weird changes, I need to refactor my code just because Mozilla guys decided to rename something. And more over that, thank you for not sending back the driver’s version in API responses. It would be not so challenging when having it.

    I wonder why has Chromedriver been working all that time without errors? It’s written in C++ and Python and the code is quite simple and linear. And it’s much stable then its Rust-driven rival.

    OK, it’s time to summarize all together. I don’t think that anybody will share my misery on the subject. The Webdriver spec is quite specific thing, so who would really care…

    But the main point of that talk is you should never break backward capability even at the gunpoint. Just keep things working, that’s the main priority when you change something. Extend, but not truncate your product. And really, just use it sometimes in a way that an ordinary user does.

  • Последнее видео с Рефакторинга

    Клуб Рефакторинга живет и здравствует, хоть я и забываю репостить видео в блоге. Вот пачка последних выступлений:

    Ссылки на слайды в описании к каждому видео. Больше записей на нашем Ютуб-канале.

  • Silent software

    I really value such kind of software that does not interrupt you. It should have happened to you I believe: you open a notebook when thouthands of notification balloons appear above the lock screen. Icons are jumping, everything is blinking. Or you open a program that immediately shows daily tips, asks for updates and so forth. Once you’ve closed the last tooltip, you start recalling what were you about to do with that program.

    That was the reason I abandoned Firefox. Every its extension, when updated, opened a new tab with release notes. So every time I opened a browser I had to close those tabs I didn’t ask to open.

    I’ve been fighting with that as I can for a long time. I turn off all the notifications on both laptop and mobile phone. I cut unwilling elements with Adblock and dump those patters into private Git repo. If a website doesn’t have RSS feed I don’t use it. I read long texts only with Kindle.

    I cut YouTube interface completely to see only a search bar and the video player by itself. No recommended sidebar or comment feed.

    When I open my laptop willing to send a email but some program prevents me from that (no matter was is a notification or a sound) it’s a serious reason to stop using it. If any site sticks a city selection or email subscription dialog across the whole screen I close it. No worries, I’ll find another one even better.

    A rule of being not interrupted by software as important as a rule of being not interrupted by other people. Everybody has a right to be alone.

    I hope one day that simple thing will occur to those who develop software.

  • Ссылки на выходные #29

    Севодня выпуск про женщин. Гендерное равенство, все дела.

    Любите женщин!

Страница 39 из 74