Tests - harunou/frontend-clean-architecture-react-tanstack-react-query GitHub Wiki

Clean Architecture in Frontend Applications: Tests

This article shares approach to the unit, integration and end-to-end tests in frontend applications within the Clean Architecture.

Repository with example: https://github.com/harunou/react-tanstack-react-query-clean-architecture

In practice, tests written for applications that follow clean architecture offer the following benefits:

  • Clear Unit Visibility: Clean architecture makes individual units easy to identify, each with a well-defined interface.
  • Test Stability Gradient: Tests reflect the application's stability pattern: tests for the application core are the most stable, while those for the driver's layer are less stable but remain well-structured.
  • Effective Mocking: Dependencies can be easily mocked at layer boundaries, enabling true unit testing without external dependencies.

To achieve these benefits and provide comprehensive coverage, clean architecture applications leverage three complementary testing approaches:

  • Unit tests: Test individual units in isolation.
  • Integration tests: Verify the interaction between different units.
  • End-to-end (E2E) tests: Test complete user workflows and system behavior from the user's perspective.

Unit Tests

Unit tests are designed to test individual units or functions in isolation. They focus on the smallest parts of the application, ensuring that each unit works correctly on its own.

The example below demonstrates a unit test for a view unit with mocked dependencies. The test only needs to change if the view unit itself changes.

// Orders.text.tsx
// Mock dependencies for the Orders component and its hooks
vi.mock("./hooks/useController");
vi.mock("./hooks/usePresenter");
// Provide a simple mock Order component for rendering
vi.mock("../Order/Order", () => ({
  Order: (props: { orderId: OrderEntityId }) => (
    <div data-testid={`order-${props.orderId}`}>Order {props.orderId}</div>
  ),
}));

describe(`Orders`, () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("renders multiple orders", () => {
    const orderIds = ["1", "2", "3"] as OrderEntityId[];
    // Mock the presenter factory to provide test data
    vi.mocked(usePresenter).mockReturnValue({
      totalItemsQuantity: 5,
      isLoading: false,
      orderIds,
    });
    // Mock the controller factory to provide controller methods
    vi.mocked(useController).mockReturnValue({
      moduleDestroyed: vi.fn(),
    });

    render(<Orders />);

    expect(screen.getByTestId(totalItemQuantityTestId)).toHaveTextContent("5");
    expect(screen.getByText("Status: pending")).toBeInTheDocument();
    // Check that each order is rendered
    orderIds.forEach((id) => {
      expect(screen.getByTestId(`order-${id}`)).toBeInTheDocument();
    });
  });

  // ...other tests for different scenarios
});

Another approach is to extract the core logic into a pure function or a set of pure functions. For example, let's consider testing a selector unit where the selection logic has been extracted into a pure function.

// useVisibleItemIdsSelector.ts
// selector unit
export const useVisibleItemIdsSelector = (params: { orderId: string }): string[] => {
  const visibleItemsIds = useOrdersPresentationStore((state) => state.itemsFilterById);
  const order = useOrderByIdSelector(props.orderId);
  return select(order, visibleItemsIds);
};

// extracted select (core) logic
type OrderEntityForSelect = { itemEntities: Array<{ id: string }>; } | undefined;

export const select = (order: OrderEntityForSelect, visibleItemsIds: string[]): string[] => {
  return (
    order?.itemEntities
      .filter((itemId) => visibleItemsIds.includes(itemId))
      .map((itemEntity) => itemEntity.id) ?? []
  );
};

// useVisibleItemIdsSelector.test.ts
describe("select", () => {
  it("returns only visible item ids present in the order", () => {
    const order = {
      itemEntities: [
        { id: "item-1" },
        { id: "item-2" },
        { id: "item-3" },
      ],
    };
    const visibleItemsIds = ["item-2", "item-3", "item-4"];
    const result = select(order, visibleItemsIds);
    expect(result).toEqual(["item-2", "item-3"]);
  });

  // ...other tests for different scenarios
});

Integration Tests

Integration tests verify how multiple units work together as a group.

The following example demonstrates an integration test of the application core, specifically for a selector unit. This test does not depend on any external resources or interfaces and remains stable as long as the application core remains unchanged.

// useTotalItemsQuantitySelector.test.ts
interface LocalTestContext {
  Fixture: FC<PropsWithChildren<unknown>>;
  gateway: Mocked<OrdersGateway>;
  orders: OrderEntity[];
}

describe(`useTotalItemsQuantitySelector`, () => {
  beforeEach<LocalTestContext>((context) => {
    vi.useFakeTimers();

    resetOrderEntitiesFactories();
    const fakeOrderEntities = makeOrderEntities();

    // Fixture component provides the necessary context
    const { Fixture } = makeComponentFixture();
    context.Fixture = Fixture;
    // mockUseOrdersGateway mocks the orders gateway factory and returns
    // a mock instance, which is used in the test to supply test data
    context.gateway = mockUseOrdersGateway();
    context.orders = fakeOrderEntities;
  });

  afterEach(() => {
    vi.restoreAllMocks();
    vi.useRealTimers();
  });

  it<LocalTestContext>("returns total quantity of items in the order ", async (context) => {
    // The 'getOrders' mock returns data in the application core data format.
    // Regardless of which external resource the gateway connects to, this test
    // remains unchanged. It only changes if the gateway or selector interface
    // changes.
    context.gateway.getOrders.mockResolvedValueOnce(context.orders);

    const { result } = renderHook(() => useTotalItemsQuantitySelector(), {
      wrapper: context.Fixture,
    });

    await vi.runAllTimersAsync();

    // Assert that the selector returns the expected total quantity
    expect(result.current).toBe(1825);
  });

  // ...other tests for different scenarios
});

Integration tests are also useful for verifying how gateways interact with external resources. These tests are a good place to use libraries that mock HTTP requests, such as MSW (Mock Service Worker).

// RemoteOrdersGateway.test.ts
const server = setupServer();

interface LocalTestContext {
  gateway: RemoteOrdersGateway;
}

describe(`RemoteOrdersGateway`, () => {
  beforeAll(() => {
    server.listen({
      onUnhandledRequest: "error",
    });
  });
  beforeEach<LocalTestContext>((context) => {
    apiOrderDtoFactory.resetCount();
    context.gateway = RemoteOrdersGateway.make();
  });
  afterAll(() => {
    server.close();
  });
  describe("getOrders", () => {
    it<LocalTestContext>("fetches order entities", async (context) => {
      // Prepare test data that will be received by the orders external resource
      // unit as a http response
      const apiOrdersDto = apiOrderDtoFactory.list({ count: 1 });

      // Define the expected result in the application core format as returned
      // by the gateway
      const expected: OrderEntity[] = [
        {
          id: makeOrderEntityId("1"),
          userId: "75",
          itemEntities: [
            { id: makeItemEntityId("1"), productId: "59", quantity: 75 },
            { id: makeItemEntityId("2"), productId: "17", quantity: 50 },
            { id: makeItemEntityId("3"), productId: "93", quantity: 60 },
          ],
        },
      ];

      // Mock the HTTP GET request for orders external resource
      server.use(
        http.get(ordersApiUrl, () => HttpResponse.json(apiOrdersDto), {
          once: true,
        }),
      );
      // Call the gateway method and assert the result
      const result = await context.gateway.getOrders();
      expect(result).toEqual(expected);
    });
  });
});

End-to-End (E2E) Tests

End-to-end tests verify the integrity and flow of the entire application, from the view unit up to the external resource unit. The tests can share types and factories defined for external resources, as well as view identifiers (for example, test-ids) from the view units. End-to-end tests are less stable, as they depend on units, which are not part of the application core and may change more frequently.

Conclusion

Testing frontend applications that follow Clean Architecture principles can be structured and maintainable by clearly separating unit, integration, and end-to-end tests. Unit tests verify the correctness of isolated units and pure functions, integration tests check how units collaborate (for example, the application core), and end-to-end tests validate the application's behavior as a whole. Tests follow the application's stability gradient: the more stable a unit is, the more stable its corresponding tests will be.

⚠️ **GitHub.com Fallback** ⚠️