Fewer mocks, better tests
I recently refactored the tests for a small open-source project of mine, baguetter. There was nothing bad about the tests, but I wanted to apply some techniques I’ve learned reading Kent C. Dodd’s blog and elsewhere.
I wanted to make my tests simple, robust, and maintainable. If you want to call them integration tests or unit tests, that’s cool. I really don’t care (do you?). I write the tests I need to give me confidence in the code I write.
Let’s take a look at some of the changes. 1
- Unnest tests
- Create a setup function
- Create a cleanup function
- Do you have to mock that?
- Write user-focused test descriptions
Unnest tests
I removed the nested test
and describe
blocks. Having a flat structure of just test
functions makes them easier to reason about and more portable (should I decide to refactor and move some functionality into new files in the future). Everything you need to know about your test is in your test
block (or in your setup
function which should be the first line in your test block—more on that next). There’s no need to scroll up to see which beforeEach
block (or multiple 😒) affects the test.
Before
describe("baguette", () => {
let log;
beforeEach(() => {
log = jest.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
afterAll(() => {
// clean up everything
});
describe("Creating a new component with a config and everything works", () => {
test("Looks up the config from user’s config.json file", () => {
// …
});
});
});
After
test("I can use a JSON config to create new modules.", () => {
const { cleanup } = setup("__mocks__/normal");
const outputFile = path.resolve(
process.cwd(),
"src/components/Hello/Hello.jsx"
);
return baguette({
name: "Hello",
template: "react",
})
.then(() => {
return fs
.access(outputFile)
.then(() => fs.readFile(outputFile, "utf-8"))
.then((contents) => {
expect(contents).toContain("Hello");
expect(contents).not.toContain("Baguette");
})
.catch((e) => {
throw new Error("Test failed, templates not created");
});
})
.then(cleanup);
});
Create a setup function
Without nesting, you could still have beforeEach
and afterEach
blocks. But they’re bad ideas regardless. Inside your test you have no idea if there’s a beforeEach
that has an effect on your test.
And what do you do if you need to customize on a per-test basis what’s in your beforeEach
? I’ve seen some wild techniques that include reassigning module-level variables or mutating a config object—creating the same problem the out-of-sight beforeEach
does.
This is what a setup function is for. Called on the very first line of your test
block, the setup function … does exactly that. It will often accept configuration options, set up whatever fixtures, spies, or rendering your test needs, then return what your test needs to work.
For baguetter, each test operates on a different folder in __mocks__
that acts like a project’s root. I mock process.cwd()
to achieve this. My setup function takes this test folder path as an option, and sets that path to the value of process.cwd()
. Then, my code thinks __mocks__/no_folder
is your project’s root.
Here’s what this particular setup function looks like:
function setup(src) {
const cwdSpy = jest.spyOn(process, "cwd").mockReturnValue(src);
const logSpy = jest.spyOn(console, "log");
return {
logSpy,
cleanup: () => {
cwdSpy.mockRestore();
const srcPath = path.resolve(process.cwd(), src, "src");
return fs
.access(srcPath)
.then(() => fs.rmdir(srcPath, { recursive: true }))
.catch(() => {});
},
};
}
If this were a React component, I’d probably render the component in there (using React Testing Library of course).
Notice the function returns both the logSpy
that is used in a few tests, plus a cleanup function. Speaking of which…
Create a cleanup function
Usually you’ll need to do some cleanup once you are done with your test. Maybe it’s to restore a mock, or reset some state. In baguetter, I created a __mocks__
folder where the configs lived and the tests wrote to the file system. After a test was done, I no longer want those generated folders and files in my mocks. Because that wouldn’t make sense. So I delete them in my cleanup script. The cleanup function is one of the return values of setup
so I can call it at the end of my test.
First line of your test? setup()
.
Last line of your test? cleanup()
.
It’s all very explicit and right there.
test("I can choose to omit the module folder when generating a new module.", () => {
const { cleanup } = setup("__mocks__/no_folder");
return baguette({
name: "Hello",
template: "react",
}).then(() => {
// test expectations and such
})
.catch((e) => {
throw new Error("Test failed, templates not created");
})
// 💥 cleanup!
.then(cleanup);
});
});
Do you have to mock that?
Maybe. But probably not. Try mocking as little as possible in your tests. You want to test that your code works, not that your mocks work. When users are actually using whatever it is you’re building, they’re not going to be using your mocks. Test as close as to what will be running in production as you can. This can be difficult at first if you’re used to mocking all the things. But it’s worth it. Not only will you write more robust tests, it’ll help you write better code that lends itself to needing fewer mocks in the first place 2 . More pure functions, fewer side effects.
Write user-focused test descriptions
Too often we (the royal we) write test descriptions that are either embarrassingly short it('Returns true')
or describe how the code works. Your users don’t care about either. Frame the test in terms of the user’s experience. Don’t be afraid to use “I” in the descriptions. When writing tests in this way, a list of your tests can read like clear documentation of the capabilities of your software.
Tired: “Creating a new component, with a malformed config. The script exits with a log to the console.”
Wired: “If I have an invalid config, the script lets me know and doesn’t run.”
O brave new world
If you come from a world of dogmatic unit tests versus integration tests and mocking all the things, this will take some getting used to. It’ll take work to figure out how to reduce or remove mocks entirely in your tests.
You can start small. Start by unnesting your tests and creating setup and cleanup functions. I bet you can do this right now in your tests. As you start doing that, you might find other areas to improve in your tests or code. In the end it’ll help you write better, explicit, and maintainable tests and code.
You can view the complete old tests and the new ones on Github.↩︎
I highly recommend the talk “Imagine a world without mocks” by Ken Scambler which discusses this in detail.↩︎