Testing a Next.js app with backend microservices

The app we’re testing

You’re building a blog platform. The architecture is straightforward:

The data model is simple. You have a users table and a posts table:

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  username VARCHAR(50) UNIQUE NOT NULL,
  display_name VARCHAR(100) NOT NULL,
  bio TEXT,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE posts (
  id SERIAL PRIMARY KEY,
  author_id INTEGER REFERENCES users(id),
  title VARCHAR(200) NOT NULL,
  body TEXT NOT NULL,
  published BOOLEAN DEFAULT false,
  created_at TIMESTAMP DEFAULT NOW()
);

When a reader visits /posts/42, the Next.js app calls the API gateway, which calls the post service, which queries the database. When an author publishes a new post, the flow goes in reverse — form submission in the browser → Next.js API route → API gateway → post service → database insert.

The question is: how do you test all of these layers together?

Why the usual approaches fall short

You could unit test each service in isolation, but that won’t catch the bugs that happen at the boundaries. The post service might return authorId while the API gateway expects author_id. The Next.js page might assume post.body is HTML when the API returns Markdown. These are integration bugs, and they’re the most common class of production incidents in microservice architectures.

You could run Cypress or Playwright against a deployed staging environment, but then you’re sharing the database with other developers, you can’t control the data, and your tests are flaky because someone else’s test run just deleted the post you were about to assert on.

Dokkimi gives you an isolated environment with real services, a real database you control, and the ability to assert on traffic at every layer.

Seeding the database

The first thing you need is test data. Create an init file that seeds the database with known users and posts:

-- .dokkimi/blog-platform/init/seed.sql

INSERT INTO users (id, username, display_name, bio) VALUES
  (1, 'alice', 'Alice Chen', 'Staff engineer. Writes about distributed systems.'),
  (2, 'bob', 'Bob Martinez', 'Frontend dev and occasional blogger.');

INSERT INTO posts (id, author_id, title, body, published) VALUES
  (1, 1, 'Understanding consensus algorithms', 'Raft and Paxos are the two most common...', true),
  (2, 1, 'Why I switched to Postgres', 'After years of MongoDB, I finally...', true),
  (3, 2, 'CSS Grid is underrated', 'Everyone reaches for flexbox, but grid...', true),
  (4, 2, 'Draft: React Server Components', 'Still figuring this out...', false);

SELECT setval('users_id_seq', 10);
SELECT setval('posts_id_seq', 10);

The setval calls bump the sequences so new inserts don’t collide with the seeded IDs. This file runs when Dokkimi creates the database pod, before any tests execute.

Defining the services

Each service gets a shared definition file so you can reuse it across tests:

# .dokkimi/shared/web-app.yaml
type: SERVICE
name: web-app
image: my-registry/web-app:latest
port: 3000
healthCheck: /api/health
env:
  - name: API_GATEWAY_URL
    value: http://api-gateway:3000
# .dokkimi/shared/api-gateway.yaml
type: SERVICE
name: api-gateway
image: my-registry/api-gateway:latest
port: 3000
healthCheck: /health
env:
  - name: POST_SERVICE_URL
    value: http://post-service:3000
  - name: USER_SERVICE_URL
    value: http://user-service:3000
# .dokkimi/shared/post-service.yaml
type: SERVICE
name: post-service
image: my-registry/post-service:latest
port: 3000
healthCheck: /health
env:
  - name: DATABASE_URL
    value: postgresql://dokkimi:dokkimi@postgres-db:5432/dokkimi
# .dokkimi/shared/postgres-db.yaml
type: DATABASE
name: postgres-db
databaseType: postgres
initFile: ../init/seed.sql

The environment variables use Kubernetes service names (http://api-gateway:3000, postgresql://...@postgres-db:5432/...) because inside the Dokkimi namespace, each service is reachable by its name. This matches how you’d configure services in a real Kubernetes deployment.

Testing API routes directly

Not every test needs a browser. Your Next.js API routes are HTTP endpoints, so start by testing the data layer.

Here’s a test that verifies the “list published posts” flow — from API route to database and back:

name: list-published-posts
items:
  - $ref: ../shared/web-app.yaml
  - $ref: ../shared/api-gateway.yaml
  - $ref: ../shared/post-service.yaml
  - $ref: ../shared/postgres-db.yaml

steps:
  - action:
      type: http
      method: GET
      url: web-app/api/posts
    assertions:
      # The API route returned the right data
      - target: self
        assertions:
          - type: response.statusCode
            operator: eq
            value: 200
          - type: response.body
            path: $.posts.length
            operator: eq
            value: 3
          - type: response.body
            path: $.posts[0].title
            operator: eq
            value: 'CSS Grid is underrated'

      # The post service queried the database correctly
      - target: httpCall
        match:
          origin: web-app
          method: GET
          url: api-gateway/v1/posts
        assertions:
          - type: response.statusCode
            operator: eq
            value: 200

Notice the assertion on $.posts.length — the database has 4 posts but only 3 are published. This verifies that the published filter works correctly all the way through the stack, not just in the post service’s unit tests.

You can also use database steps to verify writes. Here’s a test for creating a new post:

name: create-post
items:
  - $ref: ../shared/web-app.yaml
  - $ref: ../shared/api-gateway.yaml
  - $ref: ../shared/post-service.yaml
  - $ref: ../shared/user-service.yaml
  - $ref: ../shared/postgres-db.yaml

steps:
  - action:
      type: http
      method: POST
      url: web-app/api/posts
      body:
        authorId: 1
        title: 'New post from test'
        body: 'This post was created during an integration test.'
        published: true
    assertions:
      - target: self
        assertions:
          - type: response.statusCode
            operator: eq
            value: 201
          - type: response.body
            path: $.post.id
            operator: exists
    extract:
      # extract is a step-level field — it captures values from the response
      # so subsequent steps can reference them with {{variableName}}
      newPostId: response.body.post.id

  # Verify the post exists in the database
  - action:
      type: database
      service: postgres-db
      query: "SELECT title, published FROM posts WHERE id = {{newPostId}}"
    assertions:
      - target: self
        assertions:
          - type: response.body
            path: $.rows[0].title
            operator: eq
            value: 'New post from test'
          - type: response.body
            path: $.rows[0].published
            operator: eq
            value: true

  # Verify it shows up in the listing
  - action:
      type: http
      method: GET
      url: web-app/api/posts
    assertions:
      - target: self
        assertions:
          - type: response.body
            path: $.posts.length
            operator: eq
            value: 4

The extract on the first step captures the new post’s ID, and the database step uses {{newPostId}} to query for it directly. This is a round-trip test: HTTP create → database verify → HTTP list verify.

Adding UI tests

Once your API layer is solid, add UI tests for the critical user flows. Dokkimi drives a real Chromium browser inside the same Kubernetes namespace as your services, so the browser has the same network access as a real user.

Here’s a test that loads a post page and verifies it rendered correctly:

name: view-post-page
items:
  - $ref: ../shared/web-app.yaml
  - $ref: ../shared/api-gateway.yaml
  - $ref: ../shared/post-service.yaml
  - $ref: ../shared/user-service.yaml
  - $ref: ../shared/postgres-db.yaml

steps:
  - action:
      type: ui
      url: http://web-app:3000/posts/1
      subSteps:
        - action: waitForSelector
          selector: '[data-testid="post-title"]'
        - action: screenshot
          name: post-detail-page
    assertions:
      # The page triggered a fetch to the API gateway
      - target: httpCall
        match:
          origin: web-app
          method: GET
          url: api-gateway/v1/posts/1
        assertions:
          - type: response.statusCode
            operator: eq
            value: 200
          - type: response.body
            path: $.title
            operator: eq
            value: 'Understanding consensus algorithms'

      # The author profile was also fetched
      - target: httpCall
        match:
          origin: web-app
          method: GET
          url: api-gateway/v1/users/1
        assertions:
          - type: response.body
            path: $.displayName
            operator: eq
            value: 'Alice Chen'

The browser loads the post page, which triggers server-side data fetching. Dokkimi captures the HTTP calls that the Next.js server makes to the API gateway, so you can assert on exactly what data was fetched and what was returned — even though the user only sees the rendered HTML.

Testing a full user flow

Here’s a more involved test that walks through browsing posts, creating a new one, and verifying it appears in the listing:

name: author-publish-flow
items:
  - $ref: ../shared/web-app.yaml
  - $ref: ../shared/api-gateway.yaml
  - $ref: ../shared/post-service.yaml
  - $ref: ../shared/user-service.yaml
  - $ref: ../shared/postgres-db.yaml

steps:
  # Browse the post listing
  - action:
      type: ui
      url: http://web-app:3000/posts
      subSteps:
        - action: waitForSelector
          selector: '[data-testid="post-list"]'
        - action: screenshot
          name: post-listing-before

  # Navigate to the new post form
  - action:
      type: ui
      url: http://web-app:3000/posts/new
      subSteps:
        - action: waitForSelector
          selector: '[data-testid="post-form"]'
        - action: fill
          selector: '#title'
          value: 'Integration testing with Dokkimi'
        - action: fill
          selector: '#body'
          value: 'This is a post created by an automated test. It exercises the full stack from browser to database.'
        - action: click
          selector: '[data-testid="publish-button"]'
        - action: waitForSelector
          selector: '[data-testid="post-published-toast"]'
        - action: screenshot
          name: post-published
    assertions:
      # The form submission went through the full stack
      - target: httpCall
        match:
          origin: web-app
          method: POST
          url: api-gateway/v1/posts
        assertions:
          - type: request.body
            path: $.title
            operator: eq
            value: 'Integration testing with Dokkimi'
          - type: response.statusCode
            operator: eq
            value: 201

  # Verify the post was written to the database
  - action:
      type: database
      service: postgres-db
      query: "SELECT title, published FROM posts WHERE title = 'Integration testing with Dokkimi'"
    assertions:
      - target: self
        assertions:
          - type: response.body
            path: $.rows[0].published
            operator: eq
            value: true

  # Verify it appears in the listing
  - action:
      type: ui
      url: http://web-app:3000/posts
      subSteps:
        - action: waitForSelector
          selector: '[data-testid="post-list"]'
        - action: screenshot
          name: post-listing-after
    assertions:
      - target: httpCall
        match:
          origin: web-app
          method: GET
          url: api-gateway/v1/posts
        assertions:
          - type: response.body
            path: $.posts.length
            operator: eq
            value: 4

This test hits every layer: browser interactions, Next.js API routes, the API gateway, the post service, and the database. And because the database was seeded with known data, every assertion is deterministic — there’s no guessing about how many posts should be in the listing.

Testing server-side rendering

If your Next.js pages use getServerSideProps or server components that fetch data at render time, those fetches happen before the browser receives any HTML. You can verify them by combining a UI step (which triggers the page load) with HTTP call assertions:

steps:
  - action:
      type: ui
      url: http://web-app:3000/users/alice
      subSteps:
        - action: waitForSelector
          selector: '[data-testid="user-profile"]'
        - action: screenshot
          name: alice-profile
    assertions:
      # SSR fetched the user profile
      - target: httpCall
        match:
          origin: web-app
          method: GET
          url: api-gateway/v1/users/alice
        assertions:
          - type: response.body
            path: $.displayName
            operator: eq
            value: 'Alice Chen'
          - type: response.body
            path: $.bio
            operator: eq
            value: 'Staff engineer. Writes about distributed systems.'

      # SSR also fetched the user's posts
      - target: httpCall
        match:
          origin: web-app
          method: GET
          url: api-gateway/v1/users/alice/posts
        assertions:
          - type: response.body
            path: $.posts.length
            operator: eq
            value: 2

This catches a common class of bugs: the server-side fetch returns the right data, but the page doesn’t render it correctly. The screenshot baseline lets you verify visually, and the HTTP assertions verify the data flowing through the system.

Tips for Next.js testing