In the final post of this series, we’ll cover how applications where both the frontend and the backend are built with Javascript can benefit from a novel approach to testing: in-process, JSDOM-based acceptance tests that drive the UI, but incorporate a backend that uses memory fakes for speed and determinism. The benefits are substantial: with this type of tests, we do not need to mock or fake the backend, thus eliminating a whole class of bugs and testing infrastructure. Each test runs its own instance of the backend, and since there’s no IO involved, all tests can run in parallel and no data can leak between tests. The concepts covered in this post expand upon those covered in the previous posts, so be sure to read them to gain full understanding of the approach and the tools used.
Our React App uses React Query for server-side state management, where API calls are wrapped in custom hooks, and React Router for navigation. The app’s homepage is rendered by the Shop component:
export const Shop: React.FC<ShopProps> = ({ cartId }) => {
const [ freeTextSearch, setFreeTextSearch ] = useState('');
const products = useProducts(freeTextSearch);
const { viewCart, addItem, itemCount } = useCartWidget(cartId);
return <section>
<p aria-label={`${itemCount} items in cart`}>
{itemCount} items in cart
</p>
{!!itemCount &&
<button aria-label="View cart"
role="button"
onClick={viewCart}>View cart</button>
}
<section>
<input type="text"
aria-label="free-text-search"
placeholder="Search products"
value={freeTextSearch}
onChange={(e) => setFreeTextSearch(e.target.value)}/>
<button aria-label="Search">Search</button>
</section>
<Products addItem={addItem} products={products}/>
</section>
};
Products is a pure component that renders a product gallery, where each item has a name, price, and an Add To Cart button. The useProducts hook fetches an Axios client preconfigured with the Catalog microservice base url from a React Context called IOContext. This context is initialized by either the app’s index.ts file (for production use) or by the test harness, so that the correct base urls can be injected. It calls the backend, parses the results into the same Zod schema used in the backend, then uses React Query to synchronize the loading / error states and the data itself once resolved. These are returned to the Shop component which passes the results to the Products component for rendering.
export const useProducts = (query: string) => {
const {productCatalog} = useContext(IOContext);
const url = query?.length > 0
? `/products/search?query=${query}`
: `/products`;
return useQuery(["products", query], async () => {
const res = await productCatalog.get<unknown[]>(url);
return res.data.map(p => Product.parse(p));
});
}
The useCartSummary hook also uses the same concepts, but also performs a mutation using React Query for adding a product to the cart, and deals with navigating to the Cart page:
export const useCartWidget = (cartId: string) => {
const navigate = useNavigate();
const {cart} = useContext(IOContext);
const itemCount = useQuery(queryKey: "itemCount", async () => {
const res = await cart.get<number>(`/cart/${cartId}/count`);
return res.data;
});
const addItem = useMutation( async(productId: string) => {
await cart.post<void>(`/cart/${cartId}`, { productId });
await itemCount.refetch();
});
const viewCart = () => navigate('/cart');
return {
viewCart,
addItem: addItem.mutate,
itemCount: itemCount.data,
}
}
We will reuse the same test case covered earlier in this series: a customer wants to purchase a product from an e-commerce website. It begins by creating a random product and starting a backend where the catalog contains this product using the runBackendAndRender(). This is the frontend’s Test Harness, which renders the UI using React Testing Library and connects it to the backend. When the app has been rendered, we start by asserting that the cart is empty, then add the product to the cart, and assert that it now contains one item. We then view the cart and assert that it renders a list of items that includes our product. We click the Checkout button and expect to have been redirected to the Thank You page (we assume that one-click purchase has been preconfigured) and that it lists the items in our order. We also inspect the InMemoryOrderRepository to make sure that it indeed contains an order with our product. Finally, we click Home and expect to find an empty cart.
test("a user can purchase a product, see the confirmation page and
see their order summary, after which the cart is reset",
async () => {
const product = aProduct();
using harness = await runBackendAndRender({
products: [product],
});
const {app, orderRepo} = harness;
expect(await app.findByRole('paragraph',
{ name: /0 items in cart/i })).toBeInTheDocument();
const productItem = await app.findByLabelText(product.title)
await userEvent.click(
within(productItem)
.getByRole('button', {name: /add to cart/i}));
expect(await app.findByRole('paragraph',
{ name: /1 items in cart/i })).toBeInTheDocument();
await userEvent.click(
await app.findByRole('button', { name: /view cart/i }));
expect(await app.findByRole('listitem',
{ name: product.title })).toBeTruthy();
await userEvent.click(app.getByRole('button',
{ name: /checkout/i }));
expect(await app.findByRole('heading',
{ name: /thank you/i })).toBeTruthy();
expect(await app.findByRole('listitem',
{name: product.title})).toBeTruthy();
expect(orderRepo.orders).toContainEqual(
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
name: product.title,
})
])
}));
await userEvent.click(app.getByRole('button',
{ name: /home/i }));
await expect(app.findByRole('paragraph',
{ name: /0 items in cart/i })).toBeInTheDocument();
})
The UI Test Harness is where the integration between UI and backend happens. We use the backend’s harness to run all microservices, then have each of them listen on a random TCP port. We use React Router’s MemoryRouter to provide navigation in the JSDOM environment, and then we instantiate the IOContext by passing the URLs to the different backend microservices. App is a simple component rendering the React Router’s routes, where the default route renders the Shop component. Note that we’re implementing Disposable to allow the test to use the using keyword; this is so that even if a test fails, the servers will still shut down.
async function runBackendAndRender({ products = [] }) {
const { catalogApp, ordersApp, cartApp, orderRepo } =
await runMicroservices(products)
const catalogServer = await catalogApp.listen(0, "127.0.0.1");
const ordersServer = await ordersApp.listen(0, "127.0.0.1");
const cartServer = await cartApp.listen(0, "127.0.0.1");
const app = render(<MemoryRouter>
<IOContextProvider
cartUrl={await cartApp.getUrl()}
catalogUrl={await catalogApp.getUrl()}
ordersUrl={await ordersApp.getUrl()}>
<QueryClientProvider client={new QueryClient();}>
<App/>
</QueryClientProvider>
</IOContextProvider>
</MemoryRouter>);
return {
productRepo,
orderRepo,
app,
[Symbol.dispose]: async () => {
await cartServer.close();
await catalogServer.close();
await ordersServer.close();
},
};
}
The final component is the IOContext:
type Clients = {
cart: AxiosInstance;
productCatalog: AxiosInstance;
orders: AxiosInstance;
}
export const IOContext = React.createContext<Clients>();
export const IOContextProvider =
({catalogUrl, cartUrl, ordersUrl, children}) => {
const cart = axios.create({ baseURL: cartUrl });
const productCatalog = axios.create({ baseURL: catalogUrl });
const orders = axios.create({ baseURL: ordersUrl });
return <IOContext.Provider
value={{cart, productCatalog, orders}}>
{children}
</IOContext.Provider>;
}
Using this methodology, we can cover the full breadth of logic for all of our features with Acceptance Tests that give us the best of both worlds: the large scope and readability of E2E tests, with the speed, determinism and debuggability of unit tests, where all of the logic runs in the same process with complete isolation from other test cases and the ability to place breakpoints in both frontend code and backend code. These tests only depend on the UI and thus are agnostic to architecture or API changes in the backend and as long as the features work, they will keep passing.
Note that while this example uses React and NestJS, the concept is applicable to any JS UI framework supported by Testing Library or that can render on JSDOM, and any Node.js backend that uses the Node HTTP server.
Comments