← Back to list

I Built the Frontend First. Here’s How I Made Sure the Backend Team Didn’t Break It.

Not long ago I was working on a complex project where the frontend was ready well before the backend APIs were. We had designs, we had…

Shelcia David in Coffee☕ And Code💚 · 2026-04-08 06:51 · 65 claps · 5.8 min read
#pact #testing #front-end-development #typescript #coding
Open on Medium ↗
Wiki topics: 💻 · Programming 🌐 · Web Development

I Built the Frontend First. Here’s How I Made Sure the Backend Team Didn’t Break It.

Not long ago I was working on a complex project where the frontend was ready well before the backend APIs were. We had designs, we had components, we had mock data. What we didn’t have was any guarantee that when the real API finally showed up, it would look anything like what we’d built against.

And that’s a nerve-wracking place to be.

The backend team was working in parallel. They were moving fast. Field names changed. Response shapes shifted. And every time something changed on their end, someone had to remember to tell us. Sometimes they did. Sometimes we found out when things broke in staging.

That’s when I started looking into contract testing, and specifically into Pact.

What even is a contract?

When your frontend calls an API, there’s an implicit agreement in place. Your frontend expects certain fields in a certain shape. The backend promises to return them. But that agreement usually only lives in a Confluence page, a Slack thread, or someone’s memory.

A contract makes that agreement explicit and testable. If either side breaks it, you find out immediately instead of in staging.

Why not just write end-to-end tests?

That was my first instinct too. But E2E tests need a real backend running, real data, a real environment. They’re slow and brittle and they completely fall apart when your backend isn’t ready yet, which was exactly my situation.

Contract tests are different. They run against a local mock server that Pact spins up for you. No backend needed. No shared environment. Just fast, reliable tests that tell you whether the agreement is still holding.

You’re not testing behavior. You’re testing the agreement itself. Does the backend still return what the frontend expects?

How Pact works

Pact follows a consumer-driven approach, which suited us perfectly since we were building the frontend first.

  1. The consumer (frontend) writes tests describing exactly what it needs from the API
  2. Those tests generate a contract JSON file, the “pact”
  3. The provider (backend) runs that contract against its real implementation to verify it can fulfill it
  4. CI keeps both sides honest, catching any drift before it hits production

The contract file becomes the source of truth. It lives in git, it’s versioned, and it travels between teams.

Let’s set it up from scratch

Here’s the full walk through. We’re using Pact v15, Mocha as the test runner, and Chai for assertions. All TypeScript.

The folder structure we’re building:

your-ui-project/
├── pact/
│   ├── .mocharc.json
│   ├── setup-chai.ts
│   ├── tsconfig.json
│   ├── contracts/          <- generated contract JSONs live here  
│   └── src/
│       └── consumer/
│           └── your-feature/
│               ├── fixtures
│               │   └── create.ts
│               └── create.spec.ts
└── package.json

Step 1: Install dependencies

Add these to your UI project’s package.json:

[embed]

Step 2: Configure Mocha

Create pact/.mocharc.json. This tells Mocha how to pick up and run your TypeScript test files:

[embed]

Step 3: Set up Chai

Create pact/setup-chai.ts. This runs before any test and registers the chai-as-promised plugin so you can write async assertions without it feeling painful:

[embed]

Step 4: Create your constants file

Create pact/src/constants.ts. Consumer and provider names, shared headers, and where contracts get written. Keeping this centralized saves you a lot of repetition later.

[embed]

One thing I appreciated here: using string() for the auth header means Pact checks that the header exists without caring what the actual token value is. You're not hard coding real credentials into your tests.

Step 5: Add helper utilities

Create pact/src/helpers.ts. The consumerName() helper is small but it keeps your contract filenames descriptive and consistent as the number of endpoints grows.

[embed]

Step 6: Define your TypeScript types

Create pact/src/types.ts. Type everything. It will catch mismatches between your fixtures and your actual API shapes before Pact even runs.

[embed]

Step 7: Write your fixtures

This is where most of the Pact-specific thinking happens. Fixtures define your test data and your matchers. Matchers are the key idea in Pact: instead of saying “the name field must equal John Doe”, you say “the name field must be a string”. The contract stays valid even as test data changes.

import { like, uuid, datetime, string } from '@pact-foundation/pact/src/v3/matchers'
import { ISO8601_DATETIME_FORMAT } from '../../constants'
import { CreateUserInput } from '../../types'

export const CREATE_USER_MUTATION = `
  mutation CreateUser($input: CreateUserInput!) {
    createUser(input: $input) {
      uuid
      name
      createdAt
      updatedAt
      __typename
    }
  }
`
export const createUserInput: CreateUserInput = { name: 'John Doe' }
export const createUserRequestBody = {
  operationName: 'CreateUser',
  query: CREATE_USER_MUTATION,
  variables: { input: createUserInput },
}
export const createUserRequestBodyMatcher = {
  operationName: like('CreateUser'),
  query: like(CREATE_USER_MUTATION),
  variables: { input: like({ name: 'John Doe' }) },
}
export const createUserSuccessResponseMatcher = {
  data: {
    createUser: {
      uuid: uuid('082b6218-db3d-4090-85a4-6c1d4178fc6d'),
      name: string('John Doe'),
      createdAt: datetime(ISO8601_DATETIME_FORMAT, '2025-05-20T18:25:31.02656Z'),
      updatedAt: datetime(ISO8601_DATETIME_FORMAT, '2025-05-20T18:25:31.02656Z'),
    },
  },
}

The three matchers I reached for most often on this project:

  • like(value) checks type and structure, not the exact value
  • uuid(example) validates the field is a properly formatted UUID
  • datetime(format, example) validates the field matches an ISO 8601 timestamp

Note: if you’re using Apollo, make sure __typename is in your mutation. Apollo adds it to every request automatically, and Pact will flag a mismatch if it's missing from your fixture.

Step 8: Write the test

Now the spec file. Pact spins up a mock server, your real API client calls it, and if everything matches, the contract JSON is written to disk.

import { PactV3 } from '@pact-foundation/pact'
import { expect } from 'chai'
import { Consumer, Provider, CONTRACTS_DIR, DEFAULT_REQUEST_HEADERS } from '../../constants'
import { consumerName } from '../../helpers'
import {
  createUserRequestBody,
  createUserRequestBodyMatcher,
  createUserSuccessResponseMatcher,
} from './fixtures/create'

const provider = new PactV3({
  dir: CONTRACTS_DIR,
  consumer: consumerName(Consumer.MyApp, 'user', 'create'),
  provider: Provider.MyBackend,
})
const createUser = async (baseUrl: string, token: string) => {
  const response = await fetch(`${baseUrl}/graphql`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(createUserRequestBody),
  })
  return response.json()
}
describe('My App - User - Create', () => {
  it('returns 200 and user data when creation succeeds', async () => {
    provider
      .uponReceiving('a GraphQL request to create a user')
      .withRequest({
        method: 'POST',
        path: '/graphql',
        headers: DEFAULT_REQUEST_HEADERS,
        body: createUserRequestBodyMatcher,
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: createUserSuccessResponseMatcher,
      })
    return provider.executeTest(async (mockServer) => {
      const result = await createUser(mockServer.url, 'test-token')
      expect(result).to.have.property('data')
      expect(result.data).to.have.property('createUser')
      expect(result.data.createUser).to.have.property('uuid')
      expect(result.data.createUser).to.have.property('name')
    })
  })
})

executeTest() handles everything. It starts the mock server, passes you its URL, runs your fetch call against it, checks the request matched what you defined, and writes the contract JSON if it all lines up.

Step 9: Run your tests

[embed]

If it passes, you’ll find a contract file at pact/contracts/my-app-user-create-my-backend.json. That file is what you share with the backend team. They run it against their real implementation to verify compatibility, no shared environment needed, no waiting for a deploy.

The last piece: CI

Commit your contract JSON files to git and add a CI step that regenerates them on every run and diffs against what’s committed. If they don’t match, CI fails.

That means if someone on the backend team changes a field name, CI catches it before it ever reaches staging. And if you intentionally update the contract, you review the diff, commit it, and everyone knows the agreement changed.

Here’s what that looks like in GitLab CI:

.ui-pact-consumer:
  extends: .ui-base
  stage: Test
  script:
    - cd ui/$PROJECT
    - |
      if grep -q "test:pact:consumer" package.json; then
        yarn test:pact:consumer
      else
        echo "Pact consumer tests not configured for $PROJECT"
        exit 0
      fi
  needs:
    - job: mr:run-pipeline
      optional: true
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - ui/${PROJECT}/pact/**/*
        - ui/${PROJECT}/src/graphql/**/*
        - ui/${PROJECT}/package.json
        - ci/base/*
        - ci/test/${PROJECT}.gitlab-ci.yml

pact-test:consumer:my-app:
  extends: .ui-pact-consumer
  variables:
    PROJECT: my-app
  needs:
    - job: mr:run-pipeline
      optional: true

This was the part that made the biggest difference for us in practice. The contract file in git became the thing both teams pointed at. No more “I thought we agreed on this field name” conversations.

A few things I’d tell myself earlier

  • Use like() everywhere. Exact value matching makes contracts break constantly and for no good reason.
  • One contract per feature or operation. It sounds like more files but it makes debugging so much easier.
  • Contract tests don’t replace your unit or integration tests. They cover a very specific thing: the boundary between your frontend and your backend.

If you’re in a situation where frontend and backend are moving at different speeds and you’re nervous about them falling out of sync, Pact is genuinely worth the setup time. It gave our team a shared language for API agreements and moved those “wait, you changed the response shape?” conversations from production incidents to CI failures.

That’s a trade I’ll take every time.

Stack: @pact-foundation/pact v15 · mocha v11 · TypeScript 5 · chai v5

Happy Coding !


메타데이터
post_id
8f2b5f2eaa42
slug
i-built-the-frontend-first-heres-how-i-made-sure-the-backend-team-didn-t-break-it-8f2b5f2eaa42
url
https://medium.com/techtrends-digest/i-built-the-frontend-first-heres-how-i-made-sure-the-backend-team-didn-t-break-it-8f2b5f2eaa42
canonical_url
https://medium.com/techtrends-digest/i-built-the-frontend-first-heres-how-i-made-sure-the-backend-team-didn-t-break-it-8f2b5f2eaa42
author_url
https://medium.com/@shelcia
status
ok
fetched_at
2026-06-20 20:29:01