DEV Community

Cover image for Should You Mock External Services in a Microservice Architecture?

Should You Mock External Services in a Microservice Architecture?

tl;dr 😉

Yes, but not everywhere.

If you wanna unit test:
  mock the client wrapper, and mock databases, queues, caches, and object storages.

If you wanna write integration tests which talk to an HTTP/gRPC API:
  use WireMock or a fake server.
  And make sure to use real databases, queues, caches, or object storage.

If you wanna test compatibility between independently released services:
  use contract tests.

If you wanna write e2e test for a user journey:
  use real services.
Enter fullscreen mode Exit fullscreen mode

Detailed Answer

In a microservice architecture, you should mock external services only when the goal of the test is to isolate your own code. For me this is when I wanna write integration tests for my service and the only thing I care about is that the other service is generating what it should.

As you move up the testing pyramid, mocks should gradually disappear and be replaced by more realistic tests, real protocols, real infrastructure, and eventually real services. This is for me when I wanna write e2e tests.

💡 Tip

The mistake is not "mocking". The mistake is mocking at the wrong level.

Rule of thumb:

Unit tests         -> mock as much as you like, focus on the unit under test
Integration tests  -> use WireMock or similar tools to fake servers (though I sometimes do NOT do it since it is less work to just use the service).
E2E / smoke tests  -> no mocks

In other words:

Mock behavior when you need isolation. Use real infrastructure when you need confidence or it is simply easier to NOT mock 😆.

Unit Tests

Unit tests should be fast, deterministic, in-memory, and test a single unit. At this level, you are not trying to prove that the external service works. You are trying to prove that your own business logic behaves correctly

💡 Tip

I believe reading the test suite cases would also give you a good understanding of if the unit under test has a single responsibility and it NOT doing everything.

So imagine you have a external service for audit logging:


ts
import { catchError, firstValueFrom } from 'rxjs';

class AuditLogClient {
  constructor(private readonly httpService: HttpService) {}

  log(input: any): Promise<LogResponse> {
    const { data } = await firstValueFrom(
      this.httpService.put('http://audit', input).pipe(
        catchError((error: AxiosError) => {
          this.logger.error(error.response.data);
          throw 'An error happened!';
        }),
      ),
    );
    // Some more logic...

    return data
  }
}


Enter fullscreen mode Exit fullscreen mode

And in your service layer you have something like this:


ts
class UserService {
  constructor(
    private readonly audiLogClient: AuditLogClient,
    private readonly userRepository: UserRepository,
    private readonly otpService: OtpService,
  ) {}

  @Transactional()
  updatePassword(userId: string, newPass: string, otp: string) {
    await this.otpService.assertOtp(otp);

    const user = this.userRepository.updatePassword(userId, newPass);

    await this.auditLogClient.log({
      message: 'User reset password completed successfully',
      userId,
      timestamp: new Date().toISOString(),
    });

    return user;
  }
};


Enter fullscreen mode Exit fullscreen mode

Here you do NOT really wanna test otpService or audit log service. Instead you wanna test that your logic is correct. You usually do not wanna mock fetch, axios, HttpService, or low-level HTTP functions directly if your application already has a client abstraction.

What you wanna test is what you pass to other services and if you have a retry mechanism how it works.

Integration Tests

  • One of those which is easier and also better to NOT mock is databases, queues, caches, and object stores, mocks often lie.
    • Mocking Postgres, RabbitMQ, Redis, or AWS S3 usually gives you a simplified version of reality that misses the painful parts: transactions, constraints, indexes, serialization, pagination, locking, ordering, retries, message acknowledgement, eventual consistency, permissions.
  • Mock external services whenever it is not feasible and easy to use.

Again going back to the first example, imagine you have a REST API in front of updatePassword:


ts
@Controller('users')
export class UserController {
  constructor(
    private readonly userService: UserService,
    private readonly userMapper: UserMapper,
  ) {}

  @Patch()
  updatePassword(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() updatePasswordDto: UpdatePasswordDto
  ): Promise<UserResponse> {
    const user = await this.userService.updatePassword(
      id,
      updatePasswordDto.pass,
      updatePasswordDto.otp
    );

    return this.userMapper.toUserResponse(user)
  }
};


Enter fullscreen mode Exit fullscreen mode

E2E & Smoke Tests

End-to-end tests should not use mocks. The purpose of E2E tests is to prove that the real user journey works through the real system.
That means:

  • Real services.
  • Real configuration.
  • Real auth.
  • Real database.
  • Real queues.
  • Real network paths.
  • Production-like settings.

In staging or production smoke tests, use safe synthetic data.
For example:

  • Create a test user.
  • Perform a safe transaction.
  • Verify the result.
  • Clean up if needed.
  • Capture logs, metrics, and traces.

💡 Tip

If an E2E test is flaky, do not hide the flakiness with mocks!
Fix the root cause:

  • Bad timeout.
  • Missing retry.
  • Race condition.
  • Unstable test data.
  • Bad or wrong cleanup.
  • Non-deterministic dependency.
  • Poor observability.

Contract Testing

To do contract testing you can go to the consumer service, install Pact. For example in NodeJS you can do it like this:


cmd
npm i -D @pact-foundation/pact


Enter fullscreen mode Exit fullscreen mode

Then you define the expected interactions (requests and responses) with the provider. These tests generate a Pact contract file (a JSON file) that describes what the consumer expects from the provider.

Then you need to go to the API provider and load the Pact contract file(s) (either locally or from a Pact Broker) and verify that the provider actually behaves as the consumer expects.

Do NOT forget to install it in the API provider too.

Role Package Used Purpose Output/Action
Consumer @pact-foundation/pact Define expected API interactions Generates a Pact contract file
Provider @pact-foundation/pact Verify the API meets consumer expectations Validates against Pact contract file(s)

Top comments (0)