When building an application with NestJS and Mikro-ORM in TypeScript, ensuring proper testing is essential to maintain code quality and reliability. In this post, I will cover three main testing strategies for database-related operations, each with its pros and cons.
In this approach, you set up an in-memory SQLite database during tests to simulate persistence without interacting with a real database.
Pros:
Cons:
import { MikroORM } from '@mikro-orm/core';
import { User } from './user.entity'; // example entity
import { SqliteDriver } from '@mikro-orm/sqlite';
describe('User Service - In-Memory DB', () => {
let orm: MikroORM;
beforeAll(async () => {
orm = await MikroORM.init({
entities: [User],
dbName: ':memory:',
type: 'sqlite',
});
const generator = orm.getSchemaGenerator();
await generator.createSchema();
});
afterAll(async () => {
await orm.close(true);
});
it('should persist and retrieve a user entity', async () => {
const userRepo = orm.em.getRepository(User);
const user = userRepo.create({ name: 'John Doe' });
await userRepo.persistAndFlush(user);
const retrievedUser = await userRepo.findOne({ name: 'John Doe' });
expect(retrievedUser).toBeDefined();
expect(retrievedUser.name).toBe('John Doe');
});
});
This setup is relatively straightforward, but keep in mind the limitations regarding database compatibility. Note also this approach is not recommended by the Mikro-ORM creator but in the Mikro-ORM repo it is used anyway for some tests.
Another option is to initialize Mikro-ORM with the same driver you'd use in production but prevent it from connecting to a real database by setting connect: false
. This can be a quick setup, especially when you don't need to run real database queries.
Pros:
Cons:
import { MikroORM } from '@mikro-orm/core';
import { User } from './user.entity';
describe('User Service - No DB Connection', () => {
let orm: MikroORM;
beforeAll(async () => {
orm = await MikroORM.init({
entities: [User],
dbName: 'test-db',
type: 'postgresql', // same as production
connect: false, // prevent real connection
});
});
it('should mock user creation and retrieval', async () => {
const mockUser = { id: 1, name: 'Mock User' };
const userRepo = orm.em.getRepository(User);
jest.spyOn(userRepo, 'persistAndFlush').mockImplementation(async () => mockUser);
jest.spyOn(userRepo, 'findOne').mockResolvedValue(mockUser);
await userRepo.persistAndFlush(mockUser);
const foundUser = await userRepo.findOne({ name: 'Mock User' });
expect(foundUser).toBeDefined();
expect(foundUser.name).toBe('Mock User');
});
});
This approach works well for unit tests where database interaction is mocked. However, the lack of actual persistence may make your tests less reliable.
Mocking everything is an approach where you mock both the repository methods and any related services to simulate the behavior of the database without involving the actual ORM operations. See example an example in the nestjs-realworld-example-app here.
Pros:
Cons:
import { Test, TestingModule } from '@nestjs/testing';
import { UserService } from './user.service';
import { User } from './user.entity';
import { getRepositoryToken } from '@mikro-orm/nestjs';
describe('User Service - Full Mock', () => {
let userService: UserService;
const mockRepository = {
persistAndFlush: jest.fn(),
findOne: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UserService,
{ provide: getRepositoryToken(User), useValue: mockRepository },
],
}).compile();
userService = module.get<UserService>(UserService);
});
it('should create and return a user', async () => {
const mockUser = { id: 1, name: 'Mock User' };
mockRepository.persistAndFlush.mockResolvedValue(mockUser);
mockRepository.findOne.mockResolvedValue(mockUser);
const createdUser = await userService.create({ name: 'Mock User' });
const foundUser = await userService.findOne({ name: 'Mock User' });
expect(createdUser).toEqual(mockUser);
expect(foundUser).toEqual(mockUser);
});
});
This is particularly useful in unit tests where the focus is on testing business logic rather than database interaction.
Choosing the right testing strategy depends on the scope and type of your tests:
Consider mixing and matching these approaches based on the requirements of your project to balance accuracy, speed, and simplicity.
Currently, this blog fetches data from an external REST API. You can find more details here.
In my recent work , I focused on decoupling my components from the data source. My goal was to transition from code like this:
export default async function Home({
searchParams,
}: {
searchParams?: HomeSearchParams;
}) {
const posts = await fetch("https://rest-api-url.com/");
Here, we're making a fetch
call to an external REST API to retrieve post objects.
To something like this:
export default async function Home({
searchParams,
}: {
searchParams?: HomeSearchParams;
}) {
const posts = await activeDataProvider.getAll();
With these changes, we introduced a new layer between data-fetching operations and the component itself. I refer to this layer as the "data provider." I defined an interface specifying the required and optional methods for a data provider:
export interface IDataProvider {
getAll(options: PostSearchOptions): Promise<PaginatedPosts>;
getBySlug(slug: string): Promise<Post | null>;
create?(data: Partial<Post>): Promise<Post>;
update?(slug: string, data: Partial<Post>): Promise<Post | null>;
delete?(slug: string): Promise<boolean>;
}
This approach allows us to easily switch data sources in the future. For example, if we decide to fetch data directly from a database, we would simply create a new DbDataProvider
that implements IDataProvider
.
We would then only need to update the data-providers/active.ts
file to use the new DbDataProvider
:
import { DbAPIDataProvider } from './db';
const activeDataProvider = new DbAPIDataProvider();
export default activeDataProvider;
By modifying just one file (after creating the new data provider), you can change the app's persistence layer.
Another significant benefit of this approach is improved testability. Initially, I aimed to replace the active data provider with a TestDataProvider
that returns hard-coded data for unit tests. I planned to inject the active data provider as a dependency into Next.js page components like this:
export default async function Home({
dataProvider = activeDataProvider,
searchParams,
}: HomeProps) {
...
This setup allowed me to pass the test data provider as a parameter to the component:
<Suspense>
<Home searchParams={searchParams} dataProvider={testDataProvider} />
</Suspense>
While this worked well in development, I encountered errors when running next build
, such as:
Type error: Page "app/page.tsx" has an invalid "default" export:
Type "HomeProps" is not valid.
 ELIFECYCLE  Command failed with exit code 1.
Error: Command "pnpm run build" exited with 1
The issue was that Next.js components cannot accept parameters other than params
or searchParams
(source).
Since dependency injection was not possible, I ended up using spyOn
calls in my unit tests. Although I aimed to avoid mocks and spies, I couldn't find an alternative when dependency injection wasn't feasible.
Despite this, the testability of the code improved. For example, the test case initially looked like this:
import { getPostsAndTotalPages } from "../../app/lib/fetchPosts";
test("Home page component should match the snapshot", async () => {
const searchParams = {
query: "",
page: "1",
};
const getPostsAndTotalPagesMock = getPostsAndTotalPages as Mock;
getPostsAndTotalPagesMock.mockResolvedValue({
posts: generateMockPostAPIResponse().results,
totalPages: 2,
});
const { container } = render(
<Suspense>
<Home searchParams={searchParams} />
</Suspense>
);
// Access the screen first; otherwise, toMatchSnapshot will generate an empty snapshot
await screen.findByText("Post 1");
expect(container).toMatchSnapshot();
});
After the changes, it now looks like this:
const jsonData = JSON.parse(readFileSync('tests/test-data.json', 'utf-8'));
const memoryDataProvider = new MemoryDataProvider(jsonData);
test('Component should match the snapshot', async () => {
const postSlug = 'post-1';
const params = {
slug: postSlug,
};
vi.spyOn(
activeDataProvider,
'getSinglePostFromStorage',
).mockImplementation(() => memoryDataProvider.getSinglePostFromStorage(postSlug));
const { container } = render(
<Suspense>
<SinglePostPage params={params} />
</Suspense>,
);
// Access the screen first; otherwise, toMatchSnapshot will generate an empty snapshot
await screen.findByText('Post 1');
expect(container).toMatchSnapshot();
});
The revised test case is now less coupled to the implementation details of fetching post data. This makes the tests more robust and simplifies future code changes.
I hope some of this can also be helpful for you. Happy decoupling! 🚀
Testing async components in a Next.js project can be tricky, particularly when dealing with React Server Components. The challenge arises from the need to handle asynchronous data fetching and Suspense boundaries properly.
React Server Components allow you to fetch data on the server and send it to the client, enhancing performance by reducing the amount of JavaScript required on the client-side. However, this asynchrony introduces complexities in testing.
Having asynchronous components like this introduces some challenges when writing unit tests
export type PageSearchParams = {
query?: string;
page?: string;
};
export default async function Page({
searchParams,
}: {
searchParams?: PageSearchParams;
}) {
const query = searchParams?.query || "";
const currentPage = Number(searchParams?.page) || 1;
const asyncData = await fetchSomeDataAsynchronously(query, currentPage);
return (
<>Do something with {...asyncData}</>
);
}
A common issue is that when testing async components, you might encounter empty snapshots. This is illustrated by the following example, which renders an async component but ends up with an empty snapshot:
test("This test will pass but it will generate an empty snapshot", async () => {
const { container } = render(<Page />);
expect(container).toMatchSnapshot();
});
The generated snapshot might look like this:
exports[`This test will pass but it will generate an empty snapshot 1`] = `<div />`;
If we try to perform some screen
assertions, the test will fail. For example:
test("This test will fail because the string cannot be found", async () => {
const { container } = render(<Page />);
await screen.findByText("Some text in your page"); // can't be found
});
Please note that, depending on the version of Next.js you are using, you might encounter errors like this:
Error: Objects are not valid as a React child (found: [object Promise]). If you meant to render a collection of children, use an array instead.
Check the links at the end of the post to see which package versions should help avoid these errors.
<Suspense>
and screen
To solve this issue I discovered a workaround in this GitHub issue.
The workaround is to wrap your Component with <Suspense>
to the render call. With that change the screen
assert will pass.
test("This will pass but will now pass", async () => {
const { container } = render(
<Suspense>
<Page />
</Suspense>,
);
await screen.findByText("Some text in your page");
});
From the other side, if you are only interested in asserting the snapshot like this, the snapshot will still be empty.
test("This will pass but will still generate an empty snapshot", async () => {
const { container } = render(
<Suspense>
<Page />
</Suspense>,
);
expect(container).toMatchSnapshot();
});
I discovered that for some reason that I cannot understand, if you call first a screen
assert the snapshot will finally generate a correct snapshot.
test("This will pass and will now generate a correct snapshot", async () => {
const { container } = render(
<Suspense>
<Page />
</Suspense>,
);
await screen.findByText("Some text in your page");
expect(container).toMatchSnapshot();
});
As far as I understand, the issues are caused by these features being quite new and still in development, and it is possible that by the time you read this, the issue may already be resolved.
Please note that my proposed solutions are more of a workaround and may rely on unstable package versions.
In any case, I hope this helps others successfully write unit tests for asynchronous server components ✅ 🚀.
You can check the source code of this blog to see these workarounds in action within a real-world app, or review this demo project that showcases the problem with a simple app.
Happy coding! Happy testing!