Наиболее типичный побочный эффект — взаимодействие с файлами (файловые операции). В основном это либо чтение файлов, либо запись в них. С чтением разбираться значительно проще, поэтому с него и начнем.
Чтение файлов
В большинстве случаев чтение файлов не доставляет особых проблем. Оно ничего не изменяет и выполняется локально, в отличие от сетевых запросов. Это значит, что мы вряд ли столкнемся со случайными ошибками, если у нас есть необходимый файл и нужные права.
При тестировании функций, читающих файлы, должно выполняться ровно одно условие. Функция должна позволять менять путь до файла. В таком случае, достаточно создать файл нужной структуры в фикстурах.
// Функция читает файл со списком пользователей системы и возвращает их имена
// В линуксе это файл /etc/passwd
const userNames = readUserNames();
В тестах читать /etc/passwd нельзя, потому что содержимое этого файла зависит от окружения, в котором запущены тесты. Для тестирования нужно создать файл аналогичной структуры в фикстурах и указать его при запуске функции:
import fs from 'fs';
import path from 'path';
const getFixturePath = (filename) => path.resolve(__dirname, '../__fixtures__/', filename);
test('readUserNames', () => {
// ../__fixtures__/passwd
const passwdPath = getFixturePath('passwd');
const userNames = readUserNames(passwdPath);
expect(userNames).toEqual(/* ожидаемый список */);
});
Запись файлов
С записью файлов уже сложнее. Главная проблема — отсутствие гарантированной идемпотентности. Это значит, что повторный вызов функции, записывающей файлы, может вести себя не как первый вызов, например, завершаться с ошибкой, либо приводить к другим результатам.
Почему? Представьте себе, что мы пишем тесты на функцию log(message)
, которая дописывает все переданные в нее сообщения в файл:
const log = makeLogger('development.log');
await log('first message');
// cat development.log
// first message
await log('second message');
// cat development.log
// first message
// second message
Это значит, что каждый запуск тестов будет немного другим. При первом запуске тестов создается файл для хранения логов. Затем он начнет заполняться. Это приводит к целой пачке проблем:
- Наверняка внутри этой функции процесс создания файла это особый случай, который нужно тестировать отдельно. Повторные запуски тестов перестанут проверять эту ситуацию.
- Сложнее написать предсказуемый тест. Придется дополнительно придумывать хитрые схемы, например проверять только последнюю строку в файле. Такой подход понижает качество теста.
- Не особенно критично, но все же: в процессе запуска тестов появляется файл, который постоянно растет в размерах.
При правильной организации тестов, каждый тест работает в идентичном окружении на каждом запуске. Для этого, например, можно удалять файл после выполнения каждого теста. В Jest есть хук afterEach
который выполняется после каждого теста. Эту задачу можно попробовать решить с его помощью:
import fs from 'fs';
test('log', async () => {
const log = makeLogger('development.log');
await log('first message');
const data1 = await fs.readFile('development.log', 'utf-8');
expect(data1).toEqual(/* ... */)
await log('second message');
const data2 = await fs.readFile('development.log', 'utf-8');
expect(data2).toEqual(/* ... */)
});
afterEach(async () => {
await fs.unlink('development.log');
});
В большинстве ситуаций такое решение работает нормально, но все же не во всех. Выполнение кода тестов — это не атомарная операция. Нет никакой гарантии, что хук afterEach()
выполнится. Есть много причин, по которым это может не произойти, например, из-за самого Jest.
Есть только один надежный способ делать очистку — делать это до теста, а не после, в beforeEach()
. С таким подходом есть только одна небольшая сложность. При первом запуске тестов файла нет. Это значит, что прямой вызов unlink()
завершится с ошибкой и тесты не смогут выполниться. Чтобы избежать этого, можно подавить ошибку:
import _ from 'lodash';
beforeEach(async () => {
await fs.unlink('development.log').catch(_.noop);
});
Другой вопрос при записи файлов — куда их сохранять? Однозначно избегайте записи файлов прямо внутри проекта. Если тестируемый код позволяет сконфигурировать место записи, то используйте системную временную директорию. Ее можно получить через модуль os:
import os from 'os';
console.log(os.tmpdir());
Виртуальная файловая система (ФС)
Это еще один способ тестировать код, работающий с ФС. С помощью специальной библиотеки во время тестов создается виртуальная файловая система. Она автоматически подменяет реальную файловую систему для модуля fs. Это значит, что функцию, которая тестируется, трогать не надо. Эта функция продолжает думать, что она работает с реальным диском. Вся конфигурация при этом задается снаружи:
import mock from 'mock-fs';
// Конфигурация fs
// Любые операции с этими файлами будут происходить в памяти
// без взаимодействия с реальной файловой системой
mock({
'path/to/fake/dir': {
'some-file.txt': 'file content here',
'empty-dir': {/** empty directory */}
},
'path/to/some.png': Buffer.from([8, 6, 7, 5, 3, 0, 9]),
'some/other/path': {/** another empty directory */}
});
await fs.unlink('path/to/fake/dir/some-file.txt');
Этот способ дает идемпотентность из коробки. Вызов функции mock
формирует окружение на каждый запуск с нуля. То есть достаточно добавить ее в beforeEach
и можно приступать к тестированию.
Остались вопросы? Задайте их в разделе «Обсуждение»
Вам ответят команда поддержки Хекслета или другие студенты
- Статья «Как учиться и справляться с негативными мыслями»
- Статья «Ловушки обучения»
- Статья «Сложные простые задачи по программированию»
- Вебинар «Как самостоятельно учиться»
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.