Мы, разработчики, очень любим юнит-тесты, полезность которых очевидна. И чтобы эти тесты действительно были полезными, а не приносили боль, необходимо обеспечивать их стабильность.
Наша компания разрабатывает интерфейсный фреймворк "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.
Как не надо пытаться исправить ситуацию
Спустя некоторое время выявляется нестабильный тест, который нужно исправить. Большинству разработчиков на ум сразу же приходят казалось бы очевидные решения, но на практике они не работают. Сразу покажу, на какие грабли не следует наступать, если выявилась нестабильность теста.
- Не следует пытаться отсрочить исполнение callback-а внутри теста, пытаясь увеличить значение таймаута при вызове setTimeout/setInterval ("сейчас я напишу 1001 вместо 101, и это мне поможет"). Это не поможет.
- Не следует пытаться использовать вложенные вызовы setTimeout() в тесте ("сейчас я перекину этот вызов в следующий event loop, и это мне поможет"). Это также не поможет.
- (Если вы все же решили проигнорировать пункты 1 и 2) Не следует увеличивать таймаут на выполнение теста — эта порочная практика быстро смаштабируется на другие тесты, что в итоге приведет к увеличению общего времени тестирования, а стабильности при этом не прибавится.
Как же написать тест хорошо?
Это вопрос многогранный, и все зависит от того, насколько вы готовы вложится в исправление ситуации. Я приведу несколько примеров, как это можно сделать, в порядке моей субъективной оценки: от более правильных вариантов к менее правильным. Но вы вправе руководствоваться вашей собственной системой ценностей.
Убрать асинхронность из теста путем значительного рефакторинга кода — т.е. сделать код пригодным для тестирования.
В указанном примере выносим код, меняющий состояние экземпляра, в отдельную функцию:
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 (методику для такого теста можно увидеть в следующем примере). В реальной жизни все намного сложнее, но общий смысл не меняется: декомпозируйте и упрощайте код, пытайтесь тестировать чистые функции. Да, это требует значительной переработки архитектуры, но оно стоит того.
Убрать асинхроность через внедрение зависимости, что также потребует рефакторинга, но менее затратного.
Например, предоставим возможность заменить реализацию функции 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); });
Оставить асинхронность, но использовать 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); }); } }
И в тестах это время следует просто установить на минимум.
Использовать 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(), иначе рискуете испортить тесты соседа.
Мораль
Юнит-тесты дают вам понять, какие проблемные места существуют в вашем коде. И это всегда хороший шанс что-то улучшить, особенно когда вам пришла ошибка по нестабильному тесту. Ведь это означает, что у вас есть легальные причина и время, которое вы можете потратить на совершенствование вашего кода.
ip1981
Это безумие. И код, и тесты.
AlekseyMaltsev Автор
А можно поконкретнее про клиническу картину безумства? Примеру максимально упрощены, чтобы отразить суть проблемы.
Deosis
Тест должен максимально локализовывать ошибку. А при таком подходе ошибку будут искать там, где её нет.
AlekseyMaltsev Автор
Это суть Sinon fake timers — они подменяют стандартные реализации setTimeout и setInterval, но взамен дают контроль за их исполнением. С другой стороны в тестах должно соблюдаться правило: испортил глобальный объект для теста — верни на место. Иначе последствия будут непредсказуемы.
Какой вариант вы предлагаете? Как правильно локализовать ошибку в указанном примере?