Test Migration: From Mocks to Real Database with Test Switcher

| 3 min read

Gradual Database Migration Strategy: From Mocks to Real Database

Last week, I needed to migrate a test suite from Prisma mocks to a real database to improve test reliability. In short, using mocks of your ORM is one of the worst things you can do to your test suite. They give you false confidence and make your tests hard to read and maintain.
So we wanted the mocks out, and I was tasked by my team to do that work.
However, I wanted to avoid the chaos of having all tests fail simultaneously when switching implementations. Instead, I opted for a gradual, test-by-test migration approach.

The Strategy

Here’s the step-by-step approach I used:

1. Create the Interface Layer

Fortunately, we had a repository class encapsulating the calls to the ORM. My first move was to add an interface on top of the existing repository to abstract the implementation details.

2. Build the Test Switcher

I created a new implementation called TestSwitcherXXXRepo that acts as a proxy between the two instances of the original repository.
It takes two parameters: one for the repository instance using the mocked ORM and one instance using the real database.

The proxy contains an instance property that defaults to the mocked implementation. By default, we will keep using the mocked version, which is the one in use for the tests in the current state.

The proxy also offers a switchToDatabase() method to toggle to the real database implementation.

Here is an example of a test switcher:

/*This class is not meant to stay. This is something temporary to allow for switching the implementation test by test */
export class TestSwitcherPonyCareRepository implements PonyCareRepository {
private instance: PonyCareRepository

constructor(
private mockPrismaPonyCareRepository: PrismaPonyCareRepository,
private realPrismaPonyCareRepository: PrismaPonyCareRepository,
) {
this.instance = mockPrismaPonyCareRepository
}

switchToRealDatabase() {
this.instance = this.realPrismaPonyCareRepository
}

async fetchCareSchedules(
stableId: StableId,
ponyId: PonyId,
): Promise<PonyCareSchedule[]> {
return this.instance.fetchCareSchedules(stableId, ponyId)
}

async saveCareUpdates(
careScheduleToSave: PonyCareScheduleToSave,
): Promise<void> {
return this.instance.saveCareUpdates(careScheduleToSave)
}
}

Yes, I’m lucky and working on a pony club management application.
(Unfortunately, that’s not true, and the original repository was about a less glamorous topic.)

Creating a proxy is an easy task using the refactoring tools that good IDEs have to offer.

3. Set Up the Test Infrastructure

Now it’s time to start modifying the tests.
The first thing to do is instantiate the TestSwitcherXXXRepo with both the mock and real database repositories.

The next thing is to replace the usage of the original repository with the switcher implementation.

This setup took only a few minutes. The trickiest part was remembering how to select specific interface implementations in NestJS.

4. Migrate Tests Incrementally

We now have the infrastructure in place to switch each test one by one.

For each test, start by adding testSwitcher.switchToRealDatabase() as the first line. This also serves as a clear marker of migrated tests.

Then, set up the necessary database fixtures and update the assertions to work with real data instead of mock expectations.

This step was the most time-consuming. I had to reverse-engineer each test to understand what data it needed to demonstrate the expected behavior.

This becomes significantly more challenging with complex database queries—if you have intricate queries, consider testing them at the repository level instead of mixing them with testing business behavior as well.

5. Clean Up

Once all tests are migrated, you can inject the real database repository directly, remove all switchToDatabase() calls, and delete the TestSwitcherXXXRepo implementation.

Benefits

This approach has several benefits:

  • We can fix tests one at a time, with only a single test failing at any given moment
  • We are able to push changes after each test migration, keeping CI green and enabling continuous merging
  • We maintained a working test suite throughout the migration process
  • We easily tracked migration progress through the switchToDatabase() markers

The gradual migration strategy proved to be efficient for our migration. At no point were we lost with a lot of tests failing, fearing that we would face a massive merge conflict while other team members could be working on that same test file. As often happens, the key to migrating code is to create the tools to make it a less stressful experience.

Whenever you're ready, here is how I can help you: