abelcastro.dev

Django-style test DBs in NestJS: my MikroORM integration test helper (PoC)

2025-09-11

typescripttestingmikro-orm

Re‑building Sports Dashboard: From Angular + REST to Next.js + GraphQL

2025-05-07

typescriptnext.jsgraphql

Switching from REST to GraphQL in My Blog with Minimal Code Changes

2025-01-05

testingrest-apinestjs
1
...
5
6
7
...
14

Abel Castro 2026 - checkout the source code of this page on GitHub - Privacy Policy

TL;DR

Coming from Django, I missed the “it just works” test database story: tests can run real queries against a real DB with migrations applied automatically. In the TypeScript ecosystem, this is usually not “default behavior”, so I built a small helper for NestJS + MikroORM that gives me a Django-like workflow for integration tests while keeping unit tests fast and DB-free.

Background: what I miss from Django testing

In Django, the test runner and ORM are part of one cohesive stack:

  • A dedicated test database is created automatically.
  • Migrations (or schema setup) are handled for you.
  • Each test (or test class) gets isolation via transactions / database flush strategies.
  • The whole thing is standardized across most Django projects.

In many TS backends (NestJS + ORM + Jest/Vitest), the ecosystem is more modular. NestJS doesn’t own your ORM; the ORM doesn’t own your test runner; and the test runner doesn’t own your DB lifecycle. Result: you assemble your own conventions.

Why this isn’t “built-in” in many TS stacks (likely reasons)

A few reasons (none are “bad”, it’s mostly ecosystem shape):

  1. Framework/ORM/test-runner separation NestJS is framework-only; MikroORM/Prisma are external; Jest/Vitest are external. No single layer feels responsible for end-to-end test DB lifecycle.
  2. Performance expectations differ JS/ S culture often optimizes for fast unit tests first; integration tests exist but are intentionally explicit/opt-in.
  3. Parallelism & isolation are harder than they look A “shared test DB” is easy; a “shared test DB with parallel test workers, deterministic cleanup, and fast migrations” is not trivial to implement.

So instead of waiting for a universal solution, I created a small helper that matches my needs.

The PoC: DatabaseTest helper

In this PR, I added a tiny utility class that:

  • connects to a dedicated *_test database,
  • creates the DB if it doesn’t exist,
  • runs migrations,
  • and lets only certain test files opt in. 

Key idea: unit tests stay unit tests (fast, mocked). Integration tests opt into “real DB”.

The code

The helper lives at test/utils/database-test.ts and builds a MikroORM config from your app config, but with a test dbName and the migrator enabled. It ensures the database exists and migrates it.

Important implementation details from the PoC:

  • dbName becomes ${commonMikroOrmConfig.dbName}_test
  • extensions: [Migrator] enables migrations
  • ensureDatabase() creates the DB if missing
  • getMigrator().up() applies pending migrations
  • teardown closes the connection but keeps the database content

Using it in tests

I added posts.integration.spec.ts as a minimal integration test showing how it feels to use: 

  • beforeAll: orm = await DatabaseTest.init()
  • afterAll: await DatabaseTest.close()
  • create an entity, persistAndFlush, clear EM, query it back, assert.

This test demonstrates real persistence + retrieval via the DB. It also includes a second test that asserts data is still there (“keeps data between tests”). This is just here to demonstrate the default behavior but in a real world project it would be a good idea to delete the created data after each test case.

What I like about this approach (benefits)

  • Django-ish developer experience for integration tests: “run tests, DB exists, migrations applied”.
  • Opt-in integration tests: you only pay the DB cost where it matters.
  • No custom CLI needed: the helper is regular TypeScript code used by Jest specs.
  • Avoids over-mocking: you can test queries, relations, constraints, migrations… the real stuff.

Current trade-offs

This is explicitly a PoC, and it has important caveats:

  1. No isolation by default The DB content is kept between tests and between runs. That’s fast, but it can create inter-test coupling unless you manage cleanup carefully.
  2. No parallel-worker safety If you run tests with multiple workers, they may collide on the same DB.
  3. Migration runtime Running migrations on test start is great, but can be slow as the migration history grows.
  4. State drift risk “Kept DB” can hide bugs if your schema/data evolves but the DB wasn’t reset as you expected.

What I would implement

If I were to evolve this from PoC to “real project quality”, I’d add:

  • Isolation strategy: truncate tables between tests/suites
  • Parallel test support: per-worker DB name (e.g. _test_w1, _test_w2).
  • Seeding/factories: a standard place for fixtures, factories, and deterministic seeds.

Closing

Django made “real DB tests” feel boring—in a good way. In NestJS + MikroORM, I had to assemble the story myself. This helper is small, explicit, and already valuable for integration tests where real queries matter.

PoC source: PR #1 in abel-castro/blog-nest .

1. Looking back

End 2023 I introduced the first version Sports Dashboard, a minimal web app that tracks the latest results and league tables for Europe’s top football competitions. If you missed that origin story, catch up here.

That version used Angular 16 and a simple Django/REST API. It worked, but I quickly stopped enjoying working with Angular. So I decided to rebuild the project using tools I actually want to use.

2. Why rebuild?

Main reasons:

  • I wanted to stop using Angular.
  • I was learning Next.js 15 and wanted to go deeper.
  • I wanted to own the full stack of a GraphQL API.
  • I wanted to stop self-hosting and intentionally accept vendor lock-in with Vercel.

3. Wins & learnings

GraphQL turned out to be a great match for football data. One query can fetch standings, match results and team info in a single request—no overfetching.

4. The new version

Here the new URL: http://sports-dashboard.abelcastro.dev. The code is also publicly available here.

Over time, I’ve shared a few posts about how my blog evolved into the Next.js project it is today. In this post, I want to dive into a recent architectural improvement and explain more about how I seamlessly switched my blog’s data source from a REST API to a GraphQL API by modifying just a handful of files.

This shift was possible thanks to the use of data providers in my project. By consistently interacting with an abstraction layer (activeDataProvider), I was able to decouple my data-fetching logic from the actual source of the data.

The beauty of this design lies in its simplicity. To change the data provider, all I had to do was:

  1. Implement a new class that adheres to the IDataProvider interface.
  2. Set an instance of that class to activeDataProvider.

That’s it! No need to rewrite logic across multiple components or refactor complex parts of the application.

The Data Provider Interface

Here’s what the current iteration of the IDataProvider interface looks like:

export interface IDataProvider {
    getAll(options: PostSearchOptions): Promise<PaginatedPosts>;
    getOneBySlug(slug: string): Promise<Post | null>;
    getPostMetadata(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 interface enforces the core methods required for interacting with my blog’s data—fetching posts, retrieving metadata, and optionally creating, updating, or deleting posts.

Abstracting with BaseDataProvider

To ensure consistency across different data providers, I implemented a BaseDataProvider class:

This class handles all the standard methods from the interface and introduces an extra layer of abstraction by defining abstract methods that subclasses must implement:

export abstract class BaseDataProvider implements IDataProvider {
    create?(data: Partial<Post>): Promise<Post> {
        throw new Error('Method not implemented.');
    }
    update?(slug: string, data: Partial<Post>): Promise<Post | null> {
        throw new Error('Method not implemented.');
    }
    delete?(slug: string): Promise<boolean> {
        throw new Error('Method not implemented.');
    }

    abstract getAllFromStorage(
        options: PostSearchOptions,
    ): Promise<PaginatedPosts>;
    abstract getOneBySlugFromStorage(slug: string): Promise<Post | null>;
    abstract getPostMetadataFromStorage(slug: string): Promise<Post | null>;

    async getAll(: ): <> {
          ( (resolve, reject) => {
             paginatedPosts =  .(options);

            (paginatedPosts);
        });
    }

     (: ): < | > {
          ( (resolve, reject) => {
             matchingPost =  .(slug);
             (matchingPost) {
                (matchingPost);
            }  {
                ();
            }
        });
    }

     (: ): < | > {
          ( (resolve, reject) => {
             matchingPost =  .(slug);
             (matchingPost) {
                (matchingPost);
            }  {
                ();
            }
        });
    }
}

These <Something>FromStorage methods are the real magic—they encapsulate the logic for interacting with the actual data source, whether it’s a REST API, a GraphQL endpoint, or even static files.

On the other hand, the BaseDataProvider provides the generic methods getAll, getOneBySlug and getPostMetadata. These are the methods that our components interact with directly. Internally, they call the appropriate getAllFromStorage, getOneBySlugFromStorage and getPostMetadataFromStorage methods.

This separation ensures that the specific details of the persistence layer are abstracted away from the components, keeping the architecture clean and decoupled.

Why This Matters

In my opinion, decoupling components from external dependencies is a powerful asset that allows us to create more resilient and testable code. While this approach introduces some overhead and requires a shift in mindset, it proves invaluable in fast-paced environments where technologies evolve and change rapidly.

By creating abstraction layers, we can make our code more adaptable, enabling smoother transitions to new tools or data sources without major rewrites. This flexibility ultimately helps future-proof the project and maintain long-term efficiency.

Of course, one could argue that maintaining this level of abstraction is easy in a small project like my blog. But my perspective is this: if I can apply such high coding standards to a personal project that generates no profit and where no one will complain if it breaks tomorrow, why shouldn’t I hold myself to the same (or even higher) standard in my professional work?

In a business environment, where the software directly contributes to revenue and people rely on the services I build, maintaining clean, adaptable, and well-structured code is even more critical.

Bad Example vs Good Example

This is an example of a component tightly coupled to the implementation of the data layer, in this case using Apollo GraphQL:

const PostList: FC = () => {
    const { data } = useQuery<PostsData, PostsVars>(GET_POSTS, {
        variables: { limit: 10, offset: 0 },
    });

    return (
        <ul>
            {data?.posts.map((post) => (
                <li key={post.id}>
                    <h2>{post.title}</h2>
                    <p>{post.text}</p>
                </li>
            ))}
        </ul>
    );
};

An one that uses the approach that I propose:

const PostList: FC = () => {
    const { data } = await activeDataProvider.getAll(options);

    return (
        <ul>
            {data?.posts.map((post) => (
                <li key={post.id}>
                    <h2>{post.title}</h2>
                    <p>{post.text}</p>
                </li>
            ))}
        </ul>
    );
};

This example does not include any imports related to GraphQL or other external dependencies. It relies solely on the activeDataProvider interface, ensuring the component remains decoupled from the underlying data-fetching implementation.

Writing Mocks

Another benefit of this approach is how easily a data provider can be replaced by writing unit tests.

Since my app relies on activeDataProvider, I can easily swap the real data provider with an in-memory mock during unit tests.

In my vitest.setup.ts file, I added a mock that replaces activeDataProvider with a lightweight, in-memory provider:

// Replace active dataProvider with MemoryDataProvider
vi.mock('./data-providers/active', async () => {
    const jsonData = JSON.parse(
        readFileSync('./tests/test-data.json', 'utf-8'),
    );
    return {
        default: new MemoryDataProvider(jsonData),
    };
});

This mock loads data from a static JSON file during tests, ensuring predictable results without external dependencies.

Testing a component that fetches posts is as simple as calling:

await activeDataProvider.getAll(options);

Since the provider is mocked, the tests run fast, and I can easily simulate various data states (empty results, errors, or populated lists).

How It Worked in Practice

During this latest iteration, I transitioned the blog to pull data from a GraphQL API by implementing a new provider class that extends BaseDataProvider. By implementing the abstract methods (getAllFromStorage, etc.) with GraphQL queries, the switch was complete.

Now, for example, whenever a component fetches posts, it calls:

await activeDataProvider.getAll(options);

The underlying provider handles the communication with the persistance layer, ensuring the component is agnostic to whether the data came from GraphQL, REST, or elsewhere.

Checkout the complete source code here.

options
PostSearchOptions
Promise
PaginatedPosts
return
new
Promise
async
const
await
this
getAllFromStorage
resolve
async
getOneBySlug
slug
string
Promise
Post
null
return
new
Promise
async
const
await
this
getOneBySlugFromStorage
if
resolve
else
resolve
null
async
getPostMetadata
slug
string
Promise
Post
null
return
new
Promise
async
const
await
this
getPostMetadataFromStorage
if
resolve
else
resolve
null