Testing with Repositories
Unit-test a service that talks to the database without a database: provide a mock under the repository's token with getRepositoryToken, script its answers, and prove the service's rules from what it asked the repository for.
What you'll learn
- Provide a mock repository under getRepositoryToken(Entity) in a testing module, so the service under test never touches a database
- Script findOneBy, save, create and delete per test with mockResolvedValue and friends, and assert what the service asked for
- Decide what a repository test proves (the service's rules) and what it cannot (the query), and when an integration test on sqljs is the right tool
CatsService now depends on a Repository<Cat>, and the Testing course taught you what to do with a dependency in a unit test: stand it in with a mock the test controls. A repository is a dependency like any other, with one twist. Its token is not a class you can name, because Repository<Cat> and Repository<Owner> are the same class at runtime; @nestjs/typeorm derives a token per entity, and it gives you the function that derives it. Once the token is in hand, the whole Testing course applies, and the service's rules can be proved without a database at all.
The token
@InjectRepository(Cat) on a constructor parameter asks for the provider registered under getRepositoryToken(Cat). In a testing module you provide your own under the same token:
const moduleRef = await Test.createTestingModule({
providers: [
UsersService,
{
provide: getRepositoryToken(User),
useValue: mockRepository,
},
],
}).compile();
UsersService is constructed for real, receives mockRepository, and never learns that it is not a Repository<User>. Nothing about TypeORM boots: no TypeOrmModule.forRoot(), no connection, no schema. That is what makes the test fast and what makes it a unit test.
The mock's shape
The mock needs the methods the service calls and nothing else. jest.fn() for each:
const repository = {
findOneBy: jest.fn(),
save: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
};
Each test scripts the answers it needs: repository.findOneBy.mockResolvedValue(tom) for the found case, .mockResolvedValue(null) for the missing one, repository.delete.mockResolvedValueOnce({ affected: 1 }) for a delete that removed a row. mockImplementation(async (cat) => cat) makes save echo what it was given, which is what a real save does after an update. And jest.clearAllMocks() in beforeEach keeps one test's calls out of the next.
The assertions come in two kinds. What the service returned or threw: await expect(service.findOne(99)).rejects.toThrow(NotFoundException). And what it asked the repository for, which is where most bugs hide: expect(repository.findOneBy).toHaveBeenCalledWith({ id: 1 }), expect(repository.save).toHaveBeenCalledWith(expect.objectContaining({ adoptable: false })), expect(repository.save).not.toHaveBeenCalled() for a path that must not write.
What this proves, and what it cannot
A repository mock proves the service's rules: a missing row becomes a 404, an adopted cat is refused, a delete that affected nothing is reported. It proves nothing about the query itself, because the query never ran: a where clause with a typo, a relation that was not loaded, a column that does not exist all pass with a mock that answers whatever you scripted. For those, write an integration test: a testing module that imports TypeOrmModule.forRoot({ type: 'sqljs', autoLoadEntities: true, synchronize: true }) and forFeature([Cat]), and runs the real repository against a fresh in-memory database per test. Both kinds have a place. Unit tests with a mock for the rules, few integration tests for the queries, and end-to-end tests for the routes.
Your task
cats/cats.service.ts is given, with the rules of the previous lessons: findOne is a 404 for a missing row, adopt refuses an adopted cat with a 409, remove is a 404 when nothing was deleted. Write cats/cats.service.spec.ts:
- A stand-in repository with
findOneBy,save,createanddeleteas mocks, provided undergetRepositoryToken(Cat), reset before each test. - One test per rule, scripting the repository's answers and asserting both the result and the calls: the create path saves what
createbuilt, a found cat, a missing cat, an adoption that saves, a refused second adoption that never saves, a delete that succeeds and one that reports nothing.
Five bugs wait in the code under test; each must fail at least one of your tests. Write the assertions with that in mind: a test that only checks the happy return value catches fewer of them than one that also checks what was saved.
When it fails
Nest can't resolve dependencies of the CatsService (?), argumentCatRepository: the provider is registered under the wrong token; usegetRepositoryToken(Cat), notRepositoryor a string.TypeError: this.cats.findOneBy is not a function: the mock lacks a method the service calls.- A bug escapes: the test that should catch it asserts too little. "adopt changes the cat and never saves" passes a test that only checks the returned cat; assert that
savewas called withadoptable: false. - A test passes alone and fails with the others: a mock's scripted answer or recorded calls leaked from the previous test; reset in
beforeEach.
Remember
{ provide: getRepositoryToken(Entity), useValue: mock }replaces a repository in a testing module.- A mock repository has the methods the service calls, as
jest.fn(), scripted per test. - Assert the calls, not only the results: what was saved, and what was never saved.
- Mocks prove rules; a real
sqljsdatabase in a test proves queries.
Stuck? Show a hint
const repository = { findOneBy: jest.fn(), save: jest.fn(), create: jest.fn(), delete: jest.fn() }; providers: [CatsService, { provide: getRepositoryToken(Cat), useValue: repository }]; jest.clearAllMocks() in beforeEach. Per test: repository.findOneBy.mockResolvedValue(cat) or (null); repository.delete.mockResolvedValueOnce({ affected: 1 }); then await expect(service.adopt(1)).rejects.toThrow(ConflictException) and expect(repository.save).not.toHaveBeenCalled().