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 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 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.
Unit tests: These are the most basic type of test. They check individual functions or classes. Unit tests tend to be very specific and detailed. They are great for catching bugs early on in the development process, but they can also be difficult to maintain over time. If you change one line of code, you might have to update dozens of unit tests. This makes it hard to keep track of all the changes you've made and makes it even harder to figure out which tests need updating.
Integration tests: These are similar to unit tests, except they check how multiple components work together. Integration tests tend to be less detailed than unit tests. They are great for catching bugs that might not show up until you've composed multiple components.
End-to-end tests: These are the most comprehensive type of test. They check how your entire application works from start to finish. End-to-end tests are great for catching bugs that might not show up until you've deployed your application into production. They are usually written against high-level requirements and are therefore less detailed than unit tests or integration tests and easier to write and maintain. They can be a very powerful tool for low-cost, high-impact testing.
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:
WunderGraph Gateway: An instance of a WunderGraph application. It's responsible for handling requests from clients to mulitple upstream services. Each upstream can be considered as an dependency.
WunderGraph Data-Source: A data-source is a single upstream service that integrated in your WunderGraph Gateway. It can be a REST API, GraphQL API, or any other type of service.
Client: A test client is an auto-generated client from your WunderGraph Gateway. It's a simple HTTP client that allows you to send requests to your WunderGraph Gateway in a type-safe manner.
Test runner: A test runner is a tool that runs your tests. In this example, we use Vitess, but you can use any other test runner.
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 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 check 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:
It verifies that the WunderNode is started correctly and the generated code is compliant with the current API specification.
It verifies that the WunderNode is able to handle requests from clients.
It verifies that custom WunderGraph hooks are working correctly.
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 has stable contracts. This allows us to mock the upstream services without having to worry about writing test 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:
match
- A function that returns true if the request matches
handler
- A function that returns the response or throws an error
times
- The number of times the mock should be called. Defaults to 1.
persist
- If true, the mock will not be removed after any number of calls. Be careful with this option, as it can lead to unexpected results if you forget to remove the mock with ts.mockServer.reset() after the test. Defaults to false.
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 ensure 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.
Top comments (3)
pretty cool post!
Another good thing with tests is for maintenance and evolution. Every time you add a new feature, you want to automate some tests to prevent regressions.
A big caveat, though. You write your own tests, meaning you must be careful during that step.
Not all parts of the code need unit tests. Unit tests are specific and cannot test global state, by definition.
You probably need e2e and other kinds of tests in your app.
Cool library and I agree with @jmau111
In the beginning, I wouldn't care too much about tests. Once the project reaches a certain level of stability, I would carefully introduce tests for the critical parts so that further developments won't break existing stuff.
End-to-end tests and higher-level tests are an excellent start to cover your ground. Then working towards finer unit tests for business-critical parts. If you are a one man team, I recommend budgeting the time for writing tests.
Yeah there is always a trade-off between writing tests and meeting the product deadline. And also it depends on what kind of testings you are writing at the moment.