Как тестировать код, содержащий setTimeout/setInterval под капотом

  • Tutorial

Мы, разработчики, очень любим юнит-тесты, полезность которых очевидна. И чтобы эти тесты действительно были полезными, а не приносили боль, необходимо обеспечивать их стабильность.


Наша компания разрабатывает интерфейсный фреймворк "Wasaby" и продает построенные на его базе продукты, представляющие собой облачные и десктопные приложения. Релизный цикл у нас жестко привязан к календарю, а для контроля качества продукта настроены процессы непрерывной инеграции. Мы используем Jenkins для сборок и Mocha в связке с Chai assert для юнит тестирования JavaScript кода. И недавно мы столкнулись с ситуацией, когда мониторинг сборок стал показывать, что примерно половина всех случаев их падения приходится на нестабильные юнит-тесты JavaScript. Симптоматика при этом одинаковая: отдельный тест из набора либо не успевает выполниться, либо возвращает не тот результат, что ожидается. И анализ кейсов практически всегда выявляет факт, что падает тест, содержащий вызовы функций setTimeout или setInterval в собственном, либо в тестируемом коде. О том, как правильно поступить в этой ситуации, мы и будем говорить дальше.


Почему так происходит?


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


Рассмотрим простой пример, тестируемый код — это метод класса, который асинхронно меняет состояние экземпляра. И написан он не идеально:


export class Foo {
  propToTest: boolean = false;
  methodToTest(): void {
    setTimeout(() => {
      this.propToTest = true;
    }, 100);
  }
}

Как разработчик напишет тест? Скорее всего он пойдет по пути наименьшего сопротивления, не станет менять тестируемый код и тоже вызовет setTimeout в тесте. При этом задаcт таймаут, превышающий таймаут внутри тестируемого кода (например, 101 мс вместо 100 мс). Вот так это будет выглядеть:


it('should set propToTest to true', (done: Function) => {
  const inst = new Foo();
  inst.methodToTest();
  setTimeout(() => {
    assert.isTrue(inst.propToTest);
    done();
  }, 101);
});

Чем такое решение грозит нашему разработчику? А тем, что он написал нестабильный тест, т.к. среда исполнения не гарантирует, что callback, переданный в setTimeout, будет исполнен точно спустя указанный промежуток времени. Более того — есть шанс, что данный тест не будет укладываться в предоставленный по умолчанию таймаут в 2 секунды, что также делает его нестабильным. Вероятность проявления нестабильности растет пропорционально загрузке вычислительных мощностей машины, на которой производится тестирование. На это в свою очередь влияет как общее количество запускаемых в сборке юнит-тестов, так и активность разработчиков. У нас, например, в период где-то за неделю до релиза наблюдается повышенная активность, связанная с исправлением ошибок — в это время мы наблюдаем всплеск настабильности в юнит-тестах.
Все тоже самое справедливо для случаев с использованием функции setInterval.


Как не надо пытаться исправить ситуацию


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


  1. Не следует пытаться отсрочить исполнение callback-а внутри теста, пытаясь увеличить значение таймаута при вызове setTimeout/setInterval ("сейчас я напишу 1001 вместо 101, и это мне поможет"). Это не поможет.
  2. Не следует пытаться использовать вложенные вызовы setTimeout() в тесте ("сейчас я перекину этот вызов в следующий event loop, и это мне поможет"). Это также не поможет.
  3. (Если вы все же решили проигнорировать пункты 1 и 2) Не следует увеличивать таймаут на выполнение теста — эта порочная практика быстро смаштабируется на другие тесты, что в итоге приведет к увеличению общего времени тестирования, а стабильности при этом не прибавится.

Как же написать тест хорошо?


Это вопрос многогранный, и все зависит от того, насколько вы готовы вложится в исправление ситуации. Я приведу несколько примеров, как это можно сделать, в порядке моей субъективной оценки: от более правильных вариантов к менее правильным. Но вы вправе руководствоваться вашей собственной системой ценностей.


  1. Убрать асинхронность из теста путем значительного рефакторинга кода — т.е. сделать код пригодным для тестирования.
    В указанном примере выносим код, меняющий состояние экземпляра, в отдельную функцию:


    export function setProperState(inst: Foo) {
      inst.propToTest = true;
    }
    
    export class Foo {
      propToTest: boolean = false;
      methodToTest(): void {
        setTimeout(() => {
          setProperState(this);
        }, 100);
      }
    }

    которую и тестируем:


    it('should set propToTest to true', () => {
      const inst = new Foo();
      assert.isFalse(inst.propToTest);
      setProperState(inst);
      assert.isTrue(inst.propToTest);
    });

    Естественно этот пример сильно упрощен, мы получается вообще не тестируем вызов setProperState внутри methodToTest (методику для такого теста можно увидеть в следующем примере). В реальной жизни все намного сложнее, но общий смысл не меняется: декомпозируйте и упрощайте код, пытайтесь тестировать чистые функции. Да, это требует значительной переработки архитектуры, но оно стоит того.


  2. Убрать асинхроность через внедрение зависимости, что также потребует рефакторинга, но менее затратного.
    Например, предоставим возможность заменить реализацию функции setTimeout на свою:


    export class Foo {
      propToTest: boolean = false;
      constructor(readonly awaiter: Function = setTimeout) {
      }
      methodToTest(): void {
        this.awaiter(() => {
          this.propToTest = true;
        }, 100);
      }
    }

    В этом случае в тесте внедряется мок-функция, код становится синхронным:


    it('should set propToTest to true', () => {
      const awaiter = (callback) => callback();
      const inst = new Foo(awaiter);
      inst.methodToTest();
      assert.isTrue(inst.propToTest);
    });

  3. Оставить асинхронность, но использовать Promise.
    Не пытайтесь в тесте подгадать момент, когда можно проверять состояние — ведь у вас уже есть готовое решение для того, чтобы точно знать об этом. А Мокка отлично работает с промизами. Но это тоже потребует переписывания кода. В нашем примере переписываем метод:


    export class Foo {
      propToTest: boolean = false;
      methodToTest(): Promise<void> {
        return new Promise((resolve) => {
          setTimeout(() => {
            this.propToTest = true;
            resolve();
          }, 100);
         });
      }
    }

    и тестируем его без использования setTimeout:


    it('should set propToTest to true', () => {
      const inst = newFoo();
      return inst.methodToTest().then(() => {
        assert.isTrue(inst.propToTest);
      });
    });

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


    export class Foo {
      propToTest: boolean = false;
      constructor(readonly asyncTimeout: number = 100) {
      }
      methodToTest(): void {
        return new Promise((resolve) => {
          setTimeout(() => {
            this.propToTest = true;
            resolve();
          }, this.asyncTimeout);
         });
      }
    }

    И в тестах это время следует просто установить на минимум.


  4. Использовать fake timers.
    Пакет Sinon.JS имеет готовое решение для работы с таймерами, которое превращает асинхронный стек в синхронный. Данный подход позволяет вам не вносить какие-либо изменения в тестируемый код вообще (в идеале), а просто подменить реализацию setTimeout из теста.
    В исходном примере оставляем код как есть:


    export class Foo {
      propToTest: boolean = false;
      methodToTest(): void {
        setTimeout(() => {
          this.propToTest = true;
        }, 100);
      }
    }

    А в тесте используем fake timer, при этом тест становится синхронным(!):


    let clock;
    
    beforeEach(() => {
      clock = sinon.useFakeTimers();
    });
    
    afterEach(() => {
      clock.restore();
    });
    
    it('should set propToTest to true', () => {
      const inst = newFoo();
      inst.methodToTest();
      clock.tick(101);
      assert.isTrue(inst.propToTest);
    });

    При использовании такого подхода не забывайте восстанавливать все "как было" через вызов clock.restore(), иначе рискуете испортить тесты соседа.



Мораль


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

Тензор
Разработчик системы СБИС

Комментарии 17

    +2

    Это безумие. И код, и тесты.

      0
      А можно поконкретнее про клиническу картину безумства? Примеру максимально упрощены, чтобы отразить суть проблемы.
        0
        При использовании такого подхода не забывайте восстанавливать все "как было" через вызов clock.restore(), иначе рискуете испортить тесты соседа.

        Тест должен максимально локализовывать ошибку. А при таком подходе ошибку будут искать там, где её нет.

          0
          Это суть Sinon fake timers — они подменяют стандартные реализации setTimeout и setInterval, но взамен дают контроль за их исполнением. С другой стороны в тестах должно соблюдаться правило: испортил глобальный объект для теста — верни на место. Иначе последствия будут непредсказуемы.

          Какой вариант вы предлагаете? Как правильно локализовать ошибку в указанном примере?
      0
      используйте jest, он предоставляет манипуляции с таймерами
        0
        Посмотрел на Jest. Сразу бросаются параллельный запуск тестов и поддержка coverage — это интересно.

        По сути обсуждения:
        — Работа а асинхронным кодом построена по той же схеме, что и в Мокка: либо через callback, либо через Promise, тут отличий нет.
        — Работа с setTimeout реализована аналогично четвертому рассмотренному в статье решению (Sinon fake timers). Плюс в примерах рассматривается ситуация посложнее — с вложенными друг в друга вызовами setTimeout. И это приятно: чувствуется, что люди разгребали похожие проблемы.

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

        Спасибо за наводку, мы к Мокке пришли давно, может уже стоить рассмотреть альтернативы.
          0
          На счет вложенности setTimeout это да, придется делать что-то свое. Хорошо, что кейс не такой распространенный.
          В целом, Jest уже 4 года, видимо отстали от общих тенденций, что часто бывает в мире фронтенда.
        0

        Когда уже Вы перестанете переводить термины?! Мокка, промизы…
        Остановитесь, пока не поздно.

          0
          Простите. Но с другой стороны язык впитывает иностранные термины, и те же «браузер» или «фрейморк» уже не кажутся чем-то инородным. Найти границу бывает сложно, иногда в подобных текстах предложения превращаются в череду русских и английских слов, что тоже не очень. Но я учту, спасибо.
          +1
          Насколько бредовой является идея абстрагировать тесты от любой асинхронщины с помощью zone.js? Может у кого-нибудь есть опыт интеграции, за пределами Angular.
            0
            В принципе идея получить больше контроля над асинхронным flow заманчива. Мы даже сделали что-то подобное для получения бОльшего количества информации в серверных логах: наш фреймворк изоморфен и работает в т.ч. на сервере, собранным в виде надстройки над V8. Там развитая система логирования, в коллстеках можно отслеживать контекст вызова некоторых других асинхронных операций.

            Насколько я понял zone.js не стандартизован?
              0
              Zone.js несколько лет используется в Angular, в том числе и в тестах. Например, хелпер fakeAsync angular.io/api/core/testing/fakeAsync даёт возможность выполнить тест практически любого асинхронного кода синхронно.

              Про стандартизацию мне ни чего не известно. А зачем?
            0

            Зачем вообще setTimeout в UI? Разве что setTimeout 0 для разрыва контекста, и то это на самом деле нужно очень редко.

              0
              Реальность жизни в асинхронном мире, использоваться может случайно либо намеренно.

              Часто это последствия каких-то оптимизаций или используемых подходов.

              Но не менее часто вставка setTimeout является костылем ad hoc, но реальность такова, что другого варианта у разработчика перед дедлайном просто нет. А потом оказывается, что у него из-за изменения кода еще и юнит-тест упал, надо исправлять. Это все плохо, в итоге копится технический долг, который надо как-то отдавать… но это уже тема отдельного разговора. Тут же я рассматриваю ситацию, как с этим жить.
                0
                Но если это ad hoc то вы получается делаете фреймворк для костылей.
                Не поймите неправильно, технически подход к проблеме верный, но я думаю что если пишутся setTimeout то это систематическая ошибка и должна решаться обучением/написанием фреймворка убирающего потребность в setTimeout.
                Тут ниже про анимации вспоминали, если приложить усилий то можно условно надёжно(целевым был только хром, в лисе тоже работает остальное не тестировалось) не только систематизировать подход к анимациям, но и за счёт обработки событий анимаций полностью избавиться от таймаутов как в коде приложения, так и в коде тестов. Мне кажется это более правильный подход, что конечно не отменяет поддержку легаси и решение проблем «уже есть 100500 setTimeout везде»
                  0
                  Да, одна из причин в нашем случае это именно legacy: у нас большой объем кода где-то 15-летней выдержки. В свое время решили зафиксировать его поведение юнит-тестами, чтобы разработчики, исправляя баг, не вносили новые.
                0
                Например таймеры часто используются в анимациях, обработке последовательностей событий и тому подобных вещах, которые завязаны на время.

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

              Самое читаемое