GraphQL offers an alternative to HTTP requests for fetching data. It offers a whole bunch of additional features that can be added to data requests.
As with Redux, GraphQL systems can seem complicated, but TDD helps to provide an approach to understanding and learning.
In this chapter, we’ll use the Relay library to connect to our backend. We’re going to build a new CustomerHistory component that displays details of a single customer and their appointment history.
This is a bare-bones GraphQL implementation that shows the fundamentals of test-driving the technology. If you’re using other GraphQL libraries instead of Relay, the techniques we’ll explore in this chapter will also apply.
Here’s what the new CustomerHistory component looks like:
Figure 13.1 – The new CustomerHistory component
This chapter covers the following topics:
By the end of the chapter, you’ll have explored the test-driven approach to GraphQL.
The code files for this chapter can be found here:
The code samples for this chapter already contain some additions:
It’s beyond the scope of this book to go into each of these, but you will need to compile the schema before you begin, which can be done by typing the following command:
The npm run build command has also been modified to run this command for you, just in case you forget. Once everything is compiled, you’re ready to write some tests.
There are a few different ways to approach the integration of Relay into a React application. The method we’ll use in this book is the fetchQuery function, which is analogous to the global.fetch function we’ve already used for standard HTTP requests.
However, Relay’s fetchQuery function has a much more complicated setup than global.fetch.
One of the parameters of the fetchQuery function is the environment, and in this section, we’ll see what that is and how to construct it.
Why Do We Need to Construct an Environment?
The Relay environment is an extension point where all manner of functionality can be added. Data caching is one example. If you’re interested in how to do that, check out the Further reading section at the end of this chapter.
We will build a function named buildEnvironment, and then another named getEnvironment that provides a singleton instance of this environment so that the initialization only needs to be done once. Both functions return an object of type Environment.
One of the arguments that the Environment constructor requires is a function named performFetch. This function, unsurprisingly, is the bit that actually fetches data – in our case, from the POST /graphql server endpoint.
In a separate test, we'll check whether performFetch is passed to the new Environment object. We need to treat performFetch as its own unit because we’re not going to be testing the behavior of the resulting environment, only its construction.
Let’s begin by creating our own performFetch function:
import {
fetchResponseOk,
fetchResponseError
} from "./builders/fetch";
import {
performFetch
} from "../src/relayEnvironment";
describe("performFetch", () => {
let response = { data: { id: 123 } };
const text = "test";
const variables = { a: 123 };
beforeEach(() => {
jest
.spyOn(global, "fetch")
.mockResolvedValue(fetchResponseOk(response));
});
});
it("sends HTTP request to POST /graphql", () => {
performFetch({ text }, variables);
expect(global.fetch).toBeCalledWith(
"/graphql",
expect.objectContaining({
method: "POST",
})
);
});
export const performFetch = (operation, variables) =>
global
.fetch("/graphql", {
method: "POST",
});
it("calls fetch with the correct configuration", () => {
performFetch({ text }, variables);
expect(global.fetch).toBeCalledWith(
"/graphql",
expect.objectContaining({
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
})
);
});
export const performFetch = (operation, variables) =>
global
.fetch("/graphql", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
});
it("calls fetch with query and variables as request body", async () => {
performFetch({ text }, variables);
expect(global.fetch).toBeCalledWith(
"/graphql",
expect.objectContaining({
body: JSON.stringify({
query: text,
variables,
}),
})
);
});
export const performFetch = (operation, variables) =>
global
.fetch("/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: operation.text,
variables
})
});
Understanding Operation, Text, and Variables
The text property of the operation argument is a static piece of data that defines the query, and the variables argument will be the piece that is relevant to this specific request.
The tests we’re writing in this chapter do not go as far as checking the behavior of this Relay plumbing code. When writing this type of unit test, which doesn’t exercise behavior, it’s important to note that some kind of end-to-end test will be necessary. That will ensure your unit tests have the right specification.
it("returns the request data", async () => {
const result = await performFetch(
{ text }, variables
);
expect(result).toEqual(response);
});
export const performFetch = (operation, variables) =>
global
.fetch("/graphql", ...)
.then(result => result.json());
it("rejects when the request fails", () => {
global.fetch.mockResolvedValue(
fetchResponseError(500)
);
return expect(
performFetch({ text }, variables)
).rejects.toEqual(new Error(500));
});
const verifyStatusOk = result => {
if (!result.ok) {
return Promise.reject(new Error(500));
} else {
return result;
}
};
export const performFetch = (operation, variables) =>
global
.fetch("/graphql", ...)
.then(verifyStatusOk)
.then(result => result.json());
You’ve now learned how to specify and test the performFetch function required for the Environment constructor. Now, we’re ready to do that construction.
We’re going to build a function named buildEnvironment, that takes all the various pieces we need to build an Environment object. The reason there are so many pieces is that they are all extension points that enable the configuration of the Relay connection.
These pieces are our performFetch function and a bunch of other Relay types that come directly from the relay-runtime package. We’ll use jest.mock to mock all these out in one fell swoop.
Let’s get started:
import {
performFetch,
buildEnvironment
} from "../src/relayEnvironment";
import {
Environment,
Network,
Store,
RecordSource
} from "relay-runtime";
jest.mock("relay-runtime");
describe("buildEnvironment", () => {
const environment = { a: 123 };
beforeEach(() => {
Environment.mockImplementation(() => environment);
});
it("returns environment", () => {
expect(buildEnvironment()).toEqual(environment);
});
});
import {
Environment,
Network,
RecordSource,
Store
} from "relay-runtime";
export const buildEnvironment = () =>
new Environment();
describe("buildEnvironment", () => {
const environment = { a: 123 };
const network = { b: 234 };
const store = { c: 345 };
beforeEach(() => {
Environment.mockImplementation(() => environment);
Network.create.mockReturnValue(network);
Store.mockImplementation(() => store);
});
it("returns environment", () => {
expect(buildEnvironment()).toEqual(environment);
});
it("calls Environment with network and store", () => {
expect(Environment).toBeCalledWith({
network,
store
});
});
});
Mocking Constructors
Note the difference in how we mock out constructors and function calls. To mock out a new Store and a new Environment, we need to use mockImplementation(fn). To mock out Network.create, we need to use mockReturnValue(returnValue).
export const buildEnvironment = () =>
new Environment({
network: Network.create(),
store: new Store()
});
it("calls Network.create with performFetch", () => {
expect(Network.create).toBeCalledWith(performFetch);
});
export const buildEnvironment = () =>
new Environment({
network: Network.create(performFetch),
store: new Store()
});
describe("buildEnvironment", () => {
...
const recordSource = { d: 456 };
beforeEach(() => {
...
RecordSource.mockImplementation(
() => recordSource
);
});
...
});
it("calls Store with RecordSource", () => {
expect(Store).toBeCalledWith(recordSource);
});
export const buildEnvironment = () =>
new Environment({
network: Network.create(performFetch),
store: new Store(new RecordSource())
});
And that, would you believe, is it for buildEnvironment! At this stage, you will have a valid Environment object.
Because creating Environment takes a substantial amount of plumbing, it’s common to construct it once and then use that value for the rest of the application.
An Alternative Approach Using RelayEnvironmentProvider
There is an alternative approach to using the singleton instance shown here, which is to use React Context. The RelayEnvironmentProvider component provided by Relay can help you with that. For more information, see the Further reading section at the end of the chapter.
Let’s build the getEnvironment function:
import {
performFetch,
buildEnvironment,
getEnvironment
} from "../src/relayEnvironment";
describe("getEnvironment", () => {
it("constructs the object only once", () => {
getEnvironment();
getEnvironment();
expect(Environment.mock.calls.length).toEqual(1);
});
});
let environment = null;
export const getEnvironment = () =>
environment || (environment = buildEnvironment());
That’s all for the environment boilerplate. We now have a shiny getEnvironment function that we can use within our React components.
In the next section, we’ll start on the CustomerHistory component.
Now that we have a Relay environment, we can begin to build out our feature. Recall from the introduction that we’re building a new CustomerHistory component that displays customer details and a list of the customer’s appointments. A GraphQL query to return this information already exists in our server, so we just need to call it in the right way. The query looks like this:
customer(id: $id) { id firstName lastName phoneNumber appointments { startsAt stylist service notes } }
This says we get a customer record for a given customer ID (specified by the $id parameter), together with a list of their appointments.
Our component will perform this query when it’s mounted. We’ll jump right in with that functionality, by testing the call to fetchQuery:
import React from "react";
import { act } from "react-dom/test-utils";
import {
initializeReactContainer,
render,
renderAndWait,
container,
element,
elements,
textOf,
} from "./reactTestExtensions";
import { fetchQuery } from "relay-runtime";
import {
CustomerHistory,
query
} from "../src/CustomerHistory";
import {
getEnvironment
} from "../src/relayEnvironment";
jest.mock("relay-runtime");
jest.mock("../src/relayEnvironment");
const date = new Date("February 16, 2019");
const appointments = [
{
startsAt: date.setHours(9, 0, 0, 0),
stylist: "Jo",
service: "Cut",
notes: "Note one"
},
{
startsAt: date.setHours(10, 0, 0, 0),
stylist: "Stevie",
service: "Cut & color",
notes: "Note two"
}
];
const customer = {
firstName: "Ashley",
lastName: "Jones",
phoneNumber: "123",
appointments
};
describe("CustomerHistory", () => {
let unsubscribeSpy = jest.fn();
const sendCustomer = ({ next }) => {
act(() => next({ customer }));
return { unsubscribe: unsubscribeSpy };
};
beforeEach(() => {
initializeReactContainer();
fetchQuery.mockReturnValue(
{ subscribe: sendCustomer }
);
});
});
The Return Value of fetchQuery
This function has a relatively complex usage pattern. A call to fetchQuery returns an object with subscribe and unsubscribe function properties We call subscribe with an object with a next callback property. That callback is called by Relay’s fetchQuery each time the query returns a result set. We can use that callback to set our component state. Finally, the unsubscribe function is returned from the useEffect block so that it’s called when the component is unmounted or the relevant props change.
it("calls fetchQuery", async () => {
await renderAndWait(<CustomerHistory id={123} />);
expect(fetchQuery).toBeCalledWith(
getEnvironment(), query, { id: 123 }
);
});
import React, { useEffect } from "react";
import { fetchQuery, graphql } from "relay-runtime";
import { getEnvironment } from "./relayEnvironment";
export const query = graphql`
query CustomerHistoryQuery($id: ID!) {
customer(id: $id) {
id
firstName
lastName
phoneNumber
appointments {
startsAt
stylist
service
notes
}
}
}
`;
export const CustomerHistory = ({ id }) => {
useEffect(() => {
fetchQuery(getEnvironment(), query, { id });
}, [id]);
return null;
};
Cannot find module './__generated__/CustomerHistoryQuery.graphql' from 'src/CustomerHistory.js'
To fix this, run the following command to compile your GraphQL query:
npx relay-compiler
it("unsubscribes when id changes", async () => {
await renderAndWait(<CustomerHistory id={123} />);
await renderAndWait(<CustomerHistory id={234} />);
expect(unsubscribeSpy).toBeCalled();
});
useEffect(() => {
const subscription = fetchQuery(
getEnvironment(), query, { id }
);
return subscription.unsubscribe;
}, [id]);
it("renders the first name and last name together in a h2", async () => {
await renderAndWait(<CustomerHistory id={123} />);
await new Promise(setTimeout);
expect(element("h2")).toContainText("Ashley Jones");
});
export const CustomerHistory = ({ id }) => {
const [customer, setCustomer] = useState(null);
useEffect(() => {
const subscription = fetchQuery(
getEnvironment(), query, { id }
).subscribe({
next: ({ customer }) => setCustomer(customer),
});
return subscription.unsubscribe;
}, [id]);
const { firstName, lastName } = customer;
return (
<>
<h2>
{firstName} {lastName}
</h2>
</>
);
it("renders the phone number", async () => {
await renderAndWait(<CustomerHistory id={123} />);
expect(document.body).toContainText("123");
});
const { firstName, lastName, phoneNumber } = customer;
return (
<>
<h2>
{firstName} {lastName}
</h2>
<p>{phoneNumber}</p>
</>
);
it("renders a Booked appointments heading", async () => {
await renderAndWait(<CustomerHistory id={123} />);
expect(element("h3")).not.toBeNull();
expect(element("h3")).toContainText(
"Booked appointments"
);
});
const { firstName, lastName, phoneNumber } = customer;
return (
<>
<h2>
{firstName} {lastName}
</h2>
<p>{phoneNumber}</p>
<h3>Booked appointments</h3>
</>
);
it("renders a table with four column headings", async () => {
await renderAndWait(<CustomerHistory id={123} />);
const headings = elements(
"table > thead > tr > th"
);
expect(textOf(headings)).toEqual([
"When",
"Stylist",
"Service",
"Notes",
]);
});
const { firstName, lastName, phoneNumber } = customer;
return (
<>
<h2>
{firstName} {lastName}
</h2>
<p>{phoneNumber}</p>
<h3>Booked appointments</h3>
<table>
<thead>
<tr>
<th>When</th>
<th>Stylist</th>
<th>Service</th>
<th>Notes</th>
</tr>
</thead>
</table>
</>
);
const columnValues = (columnNumber) =>
elements("tbody > tr").map(
(tr) => tr.childNodes[columnNumber]
);
it("renders the start time of each appointment in the correct format", async () => {
await renderAndWait(<CustomerHistory id={123} />);
expect(textOf(columnValues(0))).toEqual([
"Sat Feb 16 2019 09:00",
"Sat Feb 16 2019 10:00",
]);
});
<table>
<thead>
...
</thead>
<tbody>
{customer.appointments.map((appointment, i) => (
<AppointmentRow
appointment={appointment}
key={i}
/>
))}
</tbody>
</table>
const toTimeString = (startsAt) =>
new Date(Number(startsAt))
.toString()
.substring(0, 21);
const AppointmentRow = ({ appointment }) => (
<tr>
<td>{toTimeString(appointment.startsAt)}</td>
</tr>
);
it("renders the stylist", async () => {
await renderAndWait(<CustomerHistory id={123} />);
expect(textOf(columnValues(1))).toEqual([
"Jo", "Stevie"
]);
});
const AppointmentRow = ({ appointment }) => (
<tr>
<td>{toTimeString(appointment.startsAt)}</td>
<td>{appointment.stylist}</td>
</tr>
);
it("renders the service", async () => {
await renderAndWait(<CustomerHistory id={123} />);
expect(textOf(columnValues(2))).toEqual([
"Cut",
"Cut & color",
]);
});
const AppointmentRow = ({ appointment }) => (
<tr>
<td>{toTimeString(appointment.startsAt)}</td>
<td>{appointment.stylist}</td>
<td>{appointment.service}</td>
</tr>
);
it("renders notes", async () => {
await renderAndWait(<CustomerHistory id={123} />);
expect(textOf(columnValues(3))).toEqual([
"Note one",
"Note two",
]);
});
const AppointmentRow = ({ appointment }) => (
<tr>
<td>{toTimeString(appointment.startsAt)}</td>
<td>{appointment.stylist}</td>
<td>{appointment.service}</td>
<td>{appointment.notes}</td>
</tr>
);
describe("submitting", () => {
const noSend = () => unsubscribeSpy;
beforeEach(() => {
fetchQuery.mockReturnValue({ subscribe: noSend });
});
it("displays a loading message", async () => {
await renderAndWait(<CustomerHistory id={123} />);
expect(element("[role=alert]")).toContainText(
"Loading"
);
});
});
export const CustomerHistory = ({ id }) => {
const [customer, setCustomer] = useState(null);
useEffect(() => {
...
}, [id]);
if (!customer) {
return <p role="alert">Loading</p>;
}
...
};
describe("when there is an error fetching data", () => {
const errorSend = ({ error }) => {
act(() => error());
return { unsubscribe: unsubscribeSpy };
};
beforeEach(() => {
fetchQuery.mockReturnValue(
{ subscribe: errorSend }
);
});
it("displays an error message", async () => {
await renderAndWait(<CustomerHistory />);
expect(element("[role=alert]")).toContainText(
"Sorry, an error occurred while pulling data from the server."
);
});
});
const [customer, setCustomer] = useState(null);
const [status, setStatus] = useState("loading");
useEffect(() => {
const subscription = fetchQuery(
getEnvironment(), query, { id }
).subscribe({
next: ({ customer }) => {
setCustomer(customer);
setStatus("loaded");
},
error: (_) => setStatus("failed"),
})
return subscription.unsubscribe;
}, [id]);
if (status === "loading") {
return <p role="alert">Loading</p>;
}
if (status === "failed") {
return (
<p role="alert">
Sorry, an error occurred while pulling data from
the server.
</p>
);
}
const { firstName, lastName, phoneNumber } = customer;
...
That completes the new CustomerHistory component. You have now learned how to test-drive the use of Relay’s fetchQuery function in your application, and this component is now ready to integrate with App. This is left as an exercise.
This chapter has explored how to test-drive the integration of a GraphQL endpoint using Relay. You have seen how to test-drive the building of the Relay environment, and how to build a component that uses the fetchQuery API.
In Part 3, Interactivity, we’ll begin work in a new code base that will allow us to explore more complex use cases involving undo/redo, animation, and WebSocket manipulation.
In Chapter 14, Building a Logo Interpreter, we’ll begin by writing new Redux middleware to handle undo/redo behavior.
Integrate the CustomerHistory component into the rest of your application by taking the following steps:
The RelayEnvironmentProvider component:
https://relay.dev/docs/api-reference/relay-environment-provider/