4 Tips for better Jest tests in Typescript projects
In the world of TypeScript, Jest is pretty ubiquitous at this point. You can test anything from frontend React projects to backend Express servers and everything in between. That being said, Jest provides you with so many tools and options to test your code that it can be a bit overwhelming at times, especially when it comes to mocking your dependencies.
Not to fear, after reading this article you will know some of the best tips and patterns for structuring your tests and mocks!
Tip 1: Mock only when you have to!
You may be tempted to mock all external dependencies of your file, however this can quickly spiral out of control and can lead to your mocks creating bugs where none existed. Take this test for example:
import { createPost } from './posts' jest.mock('uuid', () => ({ v4: () => 'some-uuid' }) describe('createPost', () => { it('creates a post with an id', async () => { const post = await createPost() expect(post).toEqual({ id: 'some-uuid' }) }) it('creates a unique record', async () => { await createPost() await createPost() const items = await db.scan() // ERR: expected value to be 2, found 1 expect(items.length).toBe(2) }) })
See the problem? We're mocking the uuid
library, but because we're returning a static id, that will cause all of our posts to be created with the same id, causing record conflicts in our database. Not good!
Instead, we should trust that the uuid
package is doing its job, and use a jest matcher, like this:
import { createPost } from './posts' describe('createPost', () => { it('creates a post with an id', async () => { const post = await createPost() expect(post).toEqual({ id: expect.any(String) }) }) it('creates a unique record', async () => { await createPost() await createPost() const items = await db.scan() // no errors! expect(items.length).toBe(2) }) })
Here we used expect.any()
to tell Jest that we're ok with any value here, as long as it has the prototype of String
. You can replace String
with Number
or any other Class/Object prototype and Jest will allow any value of that type.
Tip 2: Avoid shared test scope
Your coworker comes to you with a conundrum, this test file passes when each test is ran individually, but running them all in sequence causes the third test to fail. Can you see the problem?
import { createPost } from './posts' describe('createPost', () => { const postInput = { name: 'foo' } it('creates a post with an id', async () => { const post = await createPost(postInput) expect(post).toEqual({ id: expect.any(String) }) }) it('throws if post input does not have a name', async () => { delete postInput.name await expect(() => createPost(postInput)).rejects.toThrow() }) // why does this test fail? 🤔 it('adds a timestamp', async () => { const post = await createPost(postInput) expect(post.createdAt).toBeTruthy() }) })
We deleted postInput.name
in our second test to check for the error logic, but the third test relies on that field to exist in order for the timestamp check to work. If we run these tests individually, they will work, but ran together in sequence they will fail, making the testing bug much harder to solve.
Test scope shared between tests can also lead to cases where your tests fail when ran individually but pass when ran all together if one test relies on a state mutation from a previous test.
It may be tempting to follow DRY and condense your tests down to the smallest amount of code, but you are really doing yourself a disservice in the long run. If you do have the need to share test setup, I recommend you create a builder/helper function that sets up your test inputs that can be easily overwritten rather than define something in the global test scope that is mutated between tests.
Tip 3: Preserve types when mocking!
I've seen many codebases modify their mocks like so:
import { bar } from './foo' jest.mock('./foo') describe('my module', () => { it('does stuff', () => { (bar as jest.Mock).mockResolvedValue('bing') const result = wait bar() expect(result).toBe('bing') }) })
See the problem with this one? Not as straightforward, and it has to do with the types of our functions. By casting bar
to jest.Mock
we are removing all of the types from the function, which opens us up to all sorts of bugs. We could be returning the wrong type in our tests and we wouldn't even know it! We may mock the type correctly the first time we write this test, but what happens if we change the exported bar
function to return a number instead of a string?
How do we fix this while still allowing us to override the implementation? Luckily Jest has a mocked()
utility function that you can import to transform any import into one that jest understands is mocked.
NOTE: Make sure you are actually mocking the function you wrap with jest.mocked()
, otherwise you will get an error trying to access properties that do not exist on unmocked modules.
Here's a better example:
import { bar } from './foo' jest.mock('./foo') describe('my module', () => { it('does stuff', async () => { // ts will error if this is not the correct return type jest.mocked(bar).mockResolvedValue('bing') const result = await bar() expect(result).toBe('bing') }) })
This can also be helpful for more complicated mocks. I rarely if ever define my mocks in the actual jest.mock
call and instead opt to define the implementation in a beforeAll()
or beforeEach()
hooks if I have multiple tests where I want to re-use the same mocks. All with type safety!
import { bar } from './foo' jest.mock('./foo') // set the mock up in a hook so we can still use // jest.mocked() to preserve the function type beforeEach(() => { jest.mocked(bar).mockResolvedValue('bing') }) describe('my module', () => { it('does stuff', async () => { const result = await bar() expect(result).toBe('bing') }) it('does other stuff', async () => { const result = await bar() expect(result).toBe('bing') }) })
Tip 4: Use it.each
for repeated patterns in tests
You may have a bunch of similar tests that check a function's outputs for different inputs:
import { isEven } from './numbers' describe('isEven', () => { it('returns true for 2', () => { expect(isEven(2)).toBe(true) }) it('returns false for 3', () => { expect(isEven(3)).toBe(false) }) // repeat a few more times })
This is the one case where I would recommend some usage of DRY. In this case, jest has us covered with it.each
:
import { isEven } from './numbers' describe('isEven', () => { it.each([ [1, false], [2, true], [0, true], [123, false] ])('for input %s outputs %s', (input, output) => { expect(isEven(input)).toBe(output) }) })
Be careful with this because if your test flows start to differ with the kinds of assertions you want to make, you can easily turn this into something gross and hard to maintain. Only use it.each()
when you are just changing the inputs and outputs of functions and mocks!
For example, I wouldn't attempt to use the same it.each()
call to test both the happy and unhappy paths of a function that may involve throwing/catching errors since then your tests would have to have branching logic in them. Introducing control flow in your tests makes tests harder to immediately know what is going on — if you write them correctly, your tests should be the easiest part of your codebase to understand.
You can also invoke it.each with a template literal string instead of an array, but doing it that way doesn't preserve the types of the values you pass in (they get cast to any
) so I would almost always recommend using the array method.
Conclusion
If you follow these basic rules, you'll write much better tests that are type-safe and don't give yourself and your team members headaches when code inevitably needs to change. If you disagree with these rules or have your own suggestion for testing rules with TypeScript and Jest, reach out using the contact form on my site. Thanks for reading!