Testing the Adapter functional layer in React
A unit test of the Adapter functional layer that will resist refactoring of the implementation code

Testing the Adapter functional layer in React

The Adapter is a functional layer from Clean Architecture. It contains code that communicates with the outside world.

For instance:

  • Employs fetch or axios
  • Interacts with the AWS client or supabase
  • Calls a server action (e.g., in the Nextjs project)
  • Contains any other code with a side effect (out-of-process dependency)

export async function fetchProductById(id: string) {
    const response = await axios.get<Product>(
        `/api/product/${id}`,
        { headers: { 'Accept-Language': 'de-AT' } },
    );
  
    return response.data;
}        

Writing a unit test for a code that consumes a side effect is complex because we must mock the out-of-process dependency.

jest.mock('axios');
const mock = axios as jest.Mocked<typeof axios>;

describe('fetchProductById', () => {
    it('fetches product by identity', async () => {
        mock.get.mockResolvedValue({
            data: { id: 'x', name: 'y' }, 
            status: 200,
        });
  
        const result = await fetchProductById('x');
    
        expect(mock).toHaveBeenCalledWith(
            '/api/product/x', 
            { headers: { 'Accept-Language': 'de-AT' } },
        );
        expect(result).toBe({ id: 'x', name: 'y' });
    });
});        

Mocking inner code structure always leads to a brittle test. The test will break when we replace the side effect with an alternative (e.g., fetch to axios). It's not a good test.

An effective test is resilient to the implementation. It should break only when software behavior changes. Not when the code behind the implementation is refactored. It’s also known as a black-box test.

An illustration of a black-box test in React. The best test is the one that provides a maximum value with minimum maintenance cost.


To make the test simple (mockless), we could enable passing a fake side effect. For example, via the options parameter.

async function fetchRequest(
    url: string, 
    config?: RequestConfig,
) {
    const response = await axios.get<Product>(url, config);
  
    return response.data;
}

export async function fetchProductById(
    id: string, 
    options?: Options,
) {
    const request = options?.request ?? fetchRequest;
    const response = await request(
        `/api/product/${id}`,
        { headers: { 'Accept-Language': 'de-AT' } },
    );
  
    return response.data;
}        

A slight change in the implementation design significantly simplifies testing.

describe('fetchProductById', () => {
    it('fetches product by identity', async () => {
        const fake = () => Promise.resolve({
            id: 'x', 
            name: 'y',
        });
  
        const result = await fetchProductById('x', {
            request: fake,
        });
    
        expect(fake).toHaveBeenCalledWith(
            '/api/product/x',
            { headers: { 'Accept-Language': 'de-AT' } },
        );
        expect(result).toBe({ id: 'x', name: 'y' });
    });
});        

Now, replacing the mechanism that communicates with the outside world is easy. The test will break only when we mess up the behavior of the Adapter function.


Lesson learned:

Implementation design impacts testing. Testing forces design to scale.
SUMANTH M

Frontend developer @gravityer

5mo

thanks for sharing

Tomi Ogunsan

Software Engineer | Javascript, Typescript, NextJS, ReactJS, NodeJs, NestJs, ExpressJs

5mo

Insightful!

Like
Reply
Tsvetan Tsvetanov

Sharing my journey towards more humane software organizations | Co-Owner & Senior Software Engineer @ Camplight

5mo

It's wonderful to see these practices coming to the UI bit by bit!

Salvatore Argentieri

Full-stack Typescript Engineer

5mo

Why not MSW?

Alexandre Rivest

Senior Frontend Developer at Nexapp

5mo

Great explanation! I love using the adapter layer when making web and mobile application to abstract how to communicate with a server. Personally, I do have some mixed feelings about this approach to test it. Adding a path in the code mainly to do blackbox testing feels dangerous. It opens the possibility for the developers to change the way to fetch product by id anywhere it wants, mainly to simplify testing. It may allow you to change the implementation (from REST to graphQL for example) anytime you want, but it feels like premature abstraction. Avoiding Hasty Abstraction (AHA) can greatly help the architecture of an app by keeping things simple without sacrificing testability. My strategy to test the adapter layer is more of a gray-box testing approach. I expect, for example, to deal with a REST API. However, I do not know what tools I'm using to make it work (axios, fetch, etc.). By knowing it's using REST, I can use Nock or MSW to mock the response of the server. It allows me to validate that all the logic in my adapter works as expected without mocking the actual code used to make it work. Only the element I have no control over. Thank you for the post! Great discussions happened with colleagues about it!

To view or add a comment, sign in

Insights from the community

Others also viewed

Explore topics