The most typical side effect is interacting with files (file operations). This is essentially either reading files or writing to them. Reading is much easier to deal with, so that's where we'll start.
Reading files
In most cases, reading files isn't too much trouble. It doesn't change anything, and it's executed locally, unlike network queries. This means that if you have the correct file and the correct rights, the probability of accidental errors is extremely low.
When testing functions that read files, there's one condition that must be met. The function must allow you to change the path to the file. In this case, it's enough to create a file with the desired structure in the fixtures.
// The function reads the file with the list of system users and returns their names
// On linux, this is the /etc/passwd file
const userNames = readUserNames();
You can't read /etc/passwd in tests because the contents of this file depend on the environment in which the tests are run. To test, you need to create a file with a similar structure in the fixtures and specify it when you run the function:
import fs from 'fs';
const getFixturePath = (filename) => `${__dirname}/../__fixtures__/${filename}`;
test('readUserNames', () => {
// ../__fixtures__/passwd
const passwdPath = getFixturePath('passwd');
const userNames = readUserNames(passwdPath);
expect(userNames).toEqual(/* expected list */);
});
Writing to files
Writing to files is more complicated. The main problem is the lack of guaranteed idempotency. This means that a repeated call to a function that writes files may not behave like the first call, for example, it may end with an error or lead to different results.
Why? Imagine we're writing tests for the log(message)
function, which adds all messages sent to it to a file:
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
This means that each test run will be slightly different. The first time you run the tests, a file is created to store the logs. Then it will start to fill up. This leads to a whole bunch of problems:
- The file creation process within this function will be a special case that needs to be tested separately. Running this test repeatedly will mean this situation will no longer be tested.
- It's harder to write a predictable test. You'll have to come up with additional tricky schemes, such as checking only the last line in the file. This approach lowers the quality of the test.
- This isn't particularly critical, but it's still worth noting: while tests are running, a file will appear that constantly grows in size.
If the tests are properly organized, each test will run in an identical environment each time. To do this, one option is to delete the file after each test. Jest has an afterEach
hook that runs after each test. You can try using it to solve your issue.
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');
});
In most situations, this solution works fine, but not all the time. Executing test code is not an atomic operation. There's no guarantee that the afterEach()
hook will execute. There are many reasons why this may not happen, ranging from a sudden power outage to errors in Jest itself.
The only reliable way to do a cleanup is to do it before the test, not after, using beforeEach()
There's only one small difficulty with this approach. When you run the tests for the first time, there won't be a file. This means that calling unlink()
directly will end with an error and the tests won't be able to be executed. To avoid this, you can suppress the error:
import _ from 'lodash';
beforeEach(async () => {
await fs.unlink('development.log').catch(_.noop);
});
There is another issue that can arise when writing files: Where do you save them? You should absolutely avoid writing files directly inside the project. If the code under test allows you to configure a write location, then use the system's temporary directory. It can be obtained via the os module:
import os from 'os';
console.log(os.tmpdir());
Virtual file system (FS)
This is another way to test code that works with an FS. A virtual file system is created during tests using a special library It automatically substitutes the real file system for the fs module. This means that the function being tested must not be touched. This function still thinks that it's working with the real disk. The entire configuration is set from the outside, however:
import mock from 'mock-fs';
// fs configuration
// Any operations on these files will take place in the memory
// without interacting with the real file system
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('some-file.txt');
This method gives you the idempotency out of the box. The mock
function call generates an environment for each startup from scratch. That is, just add it to the beforeEach
, and you can start testing.
Are there any more questions? Ask them in the Discussion section.
The Hexlet support team or other students will answer you.
For full access to the course you need a professional subscription.
A professional subscription will give you full access to all Hexlet courses, projects and lifetime access to the theory of lessons learned. You can cancel your subscription at any time.