As a startup, it's easy to get caught up in the excitement of building a new product. You have a great idea, you've got a team of talented people, and you're ready to change the world. But before you get too far down the road, it's important to take a step back and ask yourself: "Do I even test?"

Testing is an important part of any software development process. It helps ensure that your code works as expected, and it can also help you to catch bugs before they turn into a problem for your users.

Additionally, we're all well aware that no application comes without dependencies. At some point, you need to test contracts between your applications and your API dependencies. This is where mocking comes into play.

Mocking is a technique that allows you to replace real objects with fake ones during testing. In our scenario, we replace specific requests with fake responses.

This can significantly improve your developer experience and confidence to ship features to production, given that writing of such a test is fast and easy.

Another important aspect of mocking is that you don't want to test your application against real third-party APIs, like Stripe, Auth0, or any other API that you don't control. During tests, you really just want to test your application logic in isolation.

In this blog post, we'll discuss why testing is so important and how we build our own testing library to encourage good testing practices within our team and customers.

The Right Portion of Tests

Some people out there will tell you that testing is a waste of time. They'll say that it's too expensive, or that it slows down development.

But these arguments are based on a misunderstanding of what testing really is. Testing isn't just about making sure your code works; it's about making sure your code does what you want it to do.

And if you don't test your code, then how can you be sure that it does what you want it to do? That simple.

This becomes even more important when you're not the only one working on your codebase and your project has reached a certain size.

This will have a huge impact on your productivity because you don't feel confident to ship features to production without testing them first, and nothing is worse than a codebase that was not built with testing in mind.

This is the turning point where you wish you had tests:

Other good reasons to write tests are that they help you to document the capabilities of your application. E.g., if you're building a GraphQL Query Validator, you can write tests that document which parts of the GraphQL specification you support.

Tests are also very important for open-source projects. Accepting contributions from the community is a double-edged sword. On the one hand, it's great to have more people working on your project.

On the other hand, how can we be sure that these contributions don't break anything? The answer is simple: tests.

Without tests, it's impossible to trust someone with less experience in a codebase to make changes without breaking anything.

Let's take a step back and think about what types of tests exist.

So what is the right start? We believe that you should start with end-to-end tests. Especially, when it comes to API-integration testing.

We've built a testing library that helps you to write end-to-end tests that are easy to read and maintain.

First, let's define some terms:

Creating a WunderNode

Let's start by creating a hello country WunderGraph application. We'll use the following configuration:

const countries = introspect.graphql({
  apiNamespace: 'countries',
  url: new EnvironmentVariable(
    'COUNTRIES_URL',
    'https://countries.trevorblades.com/'
  ),
})

configureWunderGraphApplication({
  apis: [countries],
  server,
  operations,
})

This example configures a GraphQL data-Source that uses the countries’ GraphQL API. We'll create a simple GraphQL operation in .wundergraph/operations/Countries.graphql and expose the query through the WunderGraph RPC protocol:

query ($code: String) {
  countries_countries(filter: { code: { eq: $code } }) {
    code
    name
  }
}

Accessible through the following URL: http://localhost:9991/operations/Countries?code=ES

Generate a Client

Next, we need to generate a type-safe client for our operation. We can do this by running the following command in the root directory of our project:

npm exec -- wunderctl generate

This has to be done only once as long as you don't change your WunderGraph configuration.

Writing a Test

Now that we have a WunderGraph Gateway, we can write a test in your test runner of choice. Let's start by creating a new file called countries.test.ts:

import { expect, describe, it, beforeAll } from 'vitest'
import {
  createTestAndMockServer,
  TestServers,
} from '../.wundergraph/generated/testing'

let ts: TestServers

beforeAll(async () => {
  ts = createTestAndMockServer()

  return ts.start({
    mockURLEnvs: ['COUNTRIES_URL'],
  })
})

This code starts your WunderGraph Gateway and a mock server which we will configure programmatically. It also set the COUNTRIES_URL environment variable to the URL of the mock server. This allows us to use the same configuration for both production and testing environments.

Next, we want to mock the upstream request to the countries API. We can do this by using the mock method:

describe('Mock http datasource', () => {
  it('Should be able to get country based on country code', async () => {
    const scope = ts.mockServer.mock({
      match: ({ url, method }) => {
        return url.path === '/' && method === 'POST'
      },
      handler: async ({ json }) => {
        const body = await json()

        expect(body.variables.code).toEqual('ES')
        expect(body.query).toEqual(
          'query($code: String){countries_countries: countries(filter: {code: {eq: $code}}){name code}}'
        )

        return {
          body: {
            data: {
              countries_countries: [
                {
                  code: 'ES',
                  name: 'Spain',
                },
              ],
            },
          },
        }
      },
    })

    // see below ...
  })
})

Now that we have configured a mocked response, we can use the agenerated Client to make a real request against the http://localhost:9991/operations/Countries endpoint without starting up the countries API.

// ... see above

const result = await ts.testServer.client().query({
  operationName: 'CountryByCode',
  input: {
    code: 'ES',
  },
})

// If the mock was not called or nothing matches, the test will fail
scope.done()

expect(result.error).toBeUndefined()
expect(result.data).toBeDefined()
expect(result.data?.countries_countries?.[0].code).toBe('ES')

We are making a request against the WunderGraph Gateway and checking if the response is correct. If everything works as expected, the test should pass. If not, the test will fail. This simple test covers a lot of ground:

Some notes about mocking: While it provides an easy way to speed up our development process and to test edge cases that are hard to reproduce in production, it's not a replacement for real production traffic tests. It's essential that your upstream services have stable contracts.

This allows us to mock the upstream services without having to worry about writing tests against an outdated API specification.

Mock Implementation

The mock method allows you to mock any external requests that are made by your WunderGraph Gateway. It takes a single argument that is an object with the following properties:

You can use your favorite assertion library to verify that the request is correct. In this example, we use the expect function from vitest. You can also use jest or any other assertion library.

If an assertion fails or any error is thrown inside those handlers, the test will fail and the error will be rethrown when calling scope.done(). This ensures that the test runner can handle the error correctly e.g., by printing a stack trace or showing a diff.

AssertionError: expected 'ES' to deeply equal 'DE'
Caused by: No mock matched for request POST http://0.0.0.0:36331/
Expected :DE
Actual   :ES
<Click to see difference>

    at Object.handler (/c/app/countries.test.ts:29:33)

Conclusion

In this article, we've shown you how to use the WunderGraph testing library to make writing tests easier, more adaptable, and more maintainable. If you're interested in learning more about how WunderGraph, check out our documentation or get in touch with us on Twitter or Discord.


Also published here