DEV Community

Sol Lee
Sol Lee

Posted on

Develop safer web services with integrated testing

You'll learn

  • What's a unit test?
  • What's an integration test? and why we need to use it.
  • Using msw to mock the server response
  • getByText and getByTestId, which is more preferred?
  • Differences between userEvent and fireEvent
  • Why is it important to have separated, smaller tests instead of a huge, complex test code

How much do you trust your test code?

Today, with the growth of web services, the importance of UI/UX is becoming more emphasize. This leads to increasing business logic to be handled in frontend. As a result, the importance of frontend testing is naturally growing too. Developers are applying various test methods to ensure the reliability of the code. Among them, the commonly written test method is Unit Testing.

Below is an example of a **test code that verifies the behavior of the Terms and Conditions check box **and a function that returns whether the Terms and Conditions agree.

// checkAllRequiredTerms.test.ts
describe("checkAllRequiredTerms()", () => {
  it("should return true only if all the checkboxes are checked", () => {
    const terms: Term[] = [
      { required: true, term: "Term and conditions 1", isChecked: true },
      { required: true, term: "Term and conditions 2", isChecked: true },
      { required: false, term: "Term and conditions 3", isChecked: false },
    ];

    expect(checkAllRequiredTerms(terms)).toEqual(true);
  });

  it("should return false is none of the checkboxes is checked", () => { /* ... */ });
  it("should return true if there is no required terms and conditions", () => { /* ... */ });
});

// TermCheckBox.test.tsx
describe("<TermCheckBox />", () => {
  it("should call onChange and the check state should change if the checkbox is clicked", async () => {
    const user = userEvent.setup();
    const onChange = jest.fn((isChecked: boolean) => {});
    render(<TermCheckBox required={true} isChecked={false} onChange={onChange} term="term 4" />);

    const checkBox = screen.getByRole("checkbox");

    expect(screen.getByText("(required) term 4")).toBeInTheDocument();
    expect(checkBox).not.toBeChecked();

    // click the checkbox
    await user.click(checkBox);
    expect(onChange).toHaveBeenCalledWith(true);
    expect(checkBox).toBeChecked();
  });
});
Enter fullscreen mode Exit fullscreen mode

However, in practice, it is very difficult to verify the behavior of the entire application with unit tests alone. Web applications operate with dozens of components and hundreds of functions. Therefore, even if the integrity of the function and single components is guaranteed, it is difficult to predict whether each unit performs the intended operation when combined and working together.

The following code is a component that activates the "Order" button once all the required terms and conditions downloaded from the server have been checked. It is difficult to be sure that the Order Button Section component is working properly using the previously written tests alone.

// The button is enabled if all the required terms and conditions are checked
export const OrderButtonSection = () => {
  // Get API response and have it mapped 
  const { terms, updateTerms } = useFetchTermQuery();
  const isCheckedAllRequiredTerms = checkAllRequiredTerms(terms); 

  return (
    <footer>
      <TermList terms={terms} updateTerms={updateTerms} />
      <Button disabled={!isCheckedAllRequiredTerms}>Order</Button>
    </footer>
  );
};
Enter fullscreen mode Exit fullscreen mode

My team used to focus on unit testing or verifying the operation of individual components. The verification of component and module interworking was conducted by the QA team or simply delayed. To enhance this, we decided to increase the reliability of automated tests by creating integrated tests that were closer to the flow of applications than just the operation of individual components.

What is integration test?

An integration test is a test that verifies the way multiple modules or components of an application work together. If the unit test verified the individual component or module, the integration test focuses on ensuring that all or some systems work together as expected.

The code below is an integration test that tests the behavior of the OrderButtonSection component. The following test can verify that the "Order" button is activated when all the required terms and conditions downloaded from the server have been checked.

// OrderButtonSection.test.tsx
describe("<OrderButtonSection />", () => {
  it("order button should be enabled if all required checkboxes are checked", async () => {
    const user = userEvent.setup();
    mockFetchTermsAPI([
      { required: true, term: "term 1" },
      { required: false, term: "term 2" },
      { required: true, term: "term 3" },
    ]);
    render(<OrderButtonSection />);

    const loader = screen.getByTestId("loader");
    await waitFor(() => expect(loader).not.toBeInTheDocument());

    const orderButton = screen.getByText("order");
    expect(orderButton).toBeDisabled();

    // click the required checkboxes
    const requiredCheckboxs = screen
      .getAllByRole("checkbox")
      .filter((checkBox) => checkBox.hasAttribute("required"));

    for (const checkbox of requiredCheckboxs) {
      await user.click(checkbox);
    }

    expect(orderButton).not.toBeDisabled();
  });
});
Enter fullscreen mode Exit fullscreen mode

Integration tests are located between unit tests and End-to-End tests(E2E). Integration tests may have longer run times than unit tests, but they can validate a wider range of interactions.

Integration tests may be somewhat less reliable than E2E tests because they do not cover all real-world scenarios, but they provide faster feedback in the early stages of development, and help us greatly in detecting problems between modules.

The main goals of integration tests are the following:

  • Ensure that each function, component, and hooks are working together properly
  • Verify that the API response are reflected in the application and that the application works correctly based on different server response cases (e.g., success, error, loading).
  • Verifies that the application is working correctly when the user takes actions such as clicking or inputting a text. It not only tests the changes in the components that have interacted with the user, but also the overall scenario that occurs thereafter to verify the operation close to the actual environment.

Writing integration tests

Let's have a look at examples of integration tests.

The following is the PointSection components, which is in charge of the points system. It calculates the available points through getAvailablePoint function, which in turn uses the global state and the response from the server.

export const PointSection = () => {
  const { orderAmount } = useOrderContext();
  const { pointDiscountAmount, setPointDiscountAmount } = usePointContext();
  const { couponDiscountAmount } = useCouponContext();

  const { data, error, isLoading } = useQuery({
    queryKey: ["point"],
    queryFn: fetchPointBalance,
    enabled: orderAmount > 0
  });

  if (orderAmount === 0) {
    return null;
  }

  if (error) {
    return <div>Error message</div>;
  }

  if (isLoading || !data) {
    return <Loader />;
  }

  const availablePoint = getAvailablePoint(
    orderAmount,
    couponDiscountAmount,
    data.pointBalance
  );

  return (
    <div>
      <h3>Points</h3>
      <span>{pointDiscountAmount.toLocaleString()}</span>
      <span>{availablePoint.toLocaleString()}</span>
      <PointInput
        availablePoint={availablePoint}
        onChangePoint={setPointDiscountAmount}
      />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

While the individual components and functions that compose PointSection can easily be tested through unit tests, we should use integration tests to ensure its correctness in the actual application. In detail, this component has the following flow: order amount -> get the points -> calculate the available points -> render UI.

// getAvailablePoint.test.ts
describe("getAvailablePoint()", () => {
  it("user has enough points: available points = order amount - coupon amount", () => {
    const paymentAmount = 10_000;
    const couponDiscountAmount = 3_000;
    const pointBalance = 50_000;
    const availablePoint = 7_000;

    expect(
      getAvailablePoint(paymentAmount, couponDiscountAmount, pointBalance)
    ).toEqual(availablePoint);
  });
  it("user has NO enough points", () => { /* ... */ });
});

// PointInput.test.tsx
describe("<PointInput />", () => {
  it("should be enabled if no enough points", async () => {
    render(<PointInput availablePoint={0} />);
    expect(screen.getByRole("input")).toBeDisabled();
  });
  it("should only accept numbers", () => { /* ... */ });
  it("should remove all input values if reset button is clicked", () => { /* ... */ });
  it("should not input number greater than the available points", () => { /* ... */ });
});
Enter fullscreen mode Exit fullscreen mode

With this test code, let's test several cases depending on properties and server responses.

  1. Does it get the global state correctly?
  2. Is the available points from the server response applied correctly?
  3. Are the order amount and the server response correctly reflected on the UI?
  4. Is the point discount amount updated when the user types the points to use?

Let's test the case in which the order amount is zero. We mock the fetchPointBalance, which is the function that calls the API for the availbale points. Here, we'll use jest.mock. Also, this will be place at the top of the test case so that the mocked function returns the mocked response at the beginning of the simulation.

// PointSection.test.tsx
jest.mock("./apis", () => ({
  ...jest.requireActual("./apis"),
  fetchPointBalance: async () => ({ pointBalance: 0 }),
}));

/* imports */
describe.only("<PointSection />", () => {
  it("should display nothing if the order amount is zero", async () => {
    const { container } = render(<PointSection />, {
      wrapper: ({ children }) => (
        <QueryClientProvider client={new QueryClient()}>
          <MyContextProviders orderAmount={0}>{children}</MyContextProviders>
        </QueryClientProvider>
      ),
    });

    // check nothing is displayed
    expect(container.firstChild).toBeNull();
  });
});
Enter fullscreen mode Exit fullscreen mode

If you look at the render method, there is a second parameter, a wrapper. This contains the providers that the PointsSection component requires in the actual code: the QueryClientProvider and the MyContextProvider.

If you don't prefer to have these providers in every render method, there is a workaround. We can use some kind of custom renderer that can be reused:

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import { MyContextProviders } from "./MyContextProviders";

type RenderOptions = Parameters<typeof render>[1];

interface CustomOptions {
  orderAmount: number;
}

export const customRender = (
  ui: React.ReactElement,
  { orderAmount, ...options }: CustomOptions & RenderOptions
) =>
  render(ui, {
    wrapper: ({ children }) => (
      <QueryClientProvider client={new QueryClient({ ...queryOptions })}>
        <MyContextProviders orderAmount={orderAmount}>
          {children}
        </MyContextProviders>
      </QueryClientProvider>
    ),
    ...options,
  });
Enter fullscreen mode Exit fullscreen mode

Next, let's test if react-query works properly. We should take into consideration that the purpose of integration tests is testing the expected behavior of the application when handling the data from the API response, rather than testing the web API server itself. Therefore, we do not need to have the actual server running in the testing environment. Instead, we can simply mock the server responses. We'll use MSW (Mock Service Worker) for this purpose.

// PointSection.test.tsx

// mock the server response
const server = setupServer(
  http.get("/api/point", () => {
    return HttpResponse.json({ pointBalance: 1_000 });
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe("order amount is zero", () => {
  it("PointsSection is not rendered if the order amount is zero", async () => {
    const { container } = customRender(<PointSection />, { orderAmount: 0 });

    expect(container.firstChild).toBeNull();
  });
});

describe("API response testing", () => {
  it("renders the points if points API fetching succeeded", async () => {
    customRender(<PointSection />, {
      orderAmount: 10_000,
    });

    expect(screen.getByTestId("loader")).toBeInTheDocument();

    await screen.findByText("사용 가능 포인트: 1,000원");
  });
});
Enter fullscreen mode Exit fullscreen mode
  • Pro tip: In testing-library, there is a list of preferences to be used. This is inline with the philosophy that it should be as close as possible to the actual user's behaviors:
  • getByRole, getByLabelText, getByPlaceholderText, getByText, getByDisplayValue
  • getByAltText, getByTitle
  • getByTestId

By using server.use, we can overide the previous response and test several hypothtical cases. The following is the failed test case:

it("renders error message if points fetching is failed", async () => {
  server.use(
    http.get("/api/point", () =>
      HttpResponse.json({ error: "Internal Server Error" }, { status: 500 })
    )
  );
  customRender(<PointSection />, { orderAmount: 10_000 });

  expect(screen.getByTestId("loader")).toBeInTheDocument();

  await screen.findByText("error");
});
Enter fullscreen mode Exit fullscreen mode
  • Pro tip: If the global state or the mocked server response change and not initialized, other tests may get affected (and fail). If some tests fail due to unknown reasons, try to run the specific tests with only. By resetting the global state or the mocked server response we may fix this problem.

Now let's write test for user typing the points and if the component reacts properly. We can use either userEvent or fireEvent for this purpose. In my team, we use userEvent.

it("should update the points amount when the user types the points", async () => {
  const user = userEvent.setup();
  setupServer(
    http.get("/api/point", () => HttpResponse.json({ pointBalance: 1_000 }))
  );
  customRender(<PointSection />, { orderAmount: 10_000 });

  await screen.findByText("available points: 1,000원");
  expect(screen.getByText("points to use: 0원")).toBeInTheDocument();

  const input = screen.getByRole("input");
  await user.type(input, "500"); 

  expect(screen.getByText("points to use: 500원")).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode
  • Pro tip: what's the difference between fireEvent and userEvent? fireEvent produces forced events, while userEvent imitates the user's action. The latter is closer to the actual user's behavior, and it is recommended by the testing-library too.
it('should call onChangeHandler with fireEvent even if the input is disabled', () => {
  render(<input disabled onChange={onChangeHandler} placeholder="Enter text" />);

  fireEvent.change(screen.getByPlaceholderText('Enter text'), { target: { value: 'Hello' } });
  expect(onChangeHandler).toBecalled();
});

it('should not cal onChangeHandler if used with userEvent', async () => {
  const user = userEvent.setup();
  render(<input disabled onChange={onChangeHandler} placeholder="Enter text" />);

  await user.type(screen.getByPlaceholderText('Enter text'), 'Hello');
  expect(onChangeHandler).not.toBecalled();
});
Enter fullscreen mode Exit fullscreen mode

The Importance of Separating Tests

Not every action can be tested at the highest accuracy (close to the real user's action), but we need some degree of intermediate tradeoff.

As we've seen in the previous examples, DiscountMethods required more mocking than PointsSection. We will need much more server responses and external module mocking when testing the entire OrderPage. As you can see, the more complex the component the more dependent to external libraries or system, and, therefore, the more complex and time-consuming the test code becomes.

There are some ways to solve this problem. First, we can remove (refactor) repetitive tests. We can also separate them into smaller unit tests. By doing this we can leverage the effectiveness of the unit tests and the reliability of the integration test.

// DO
describe("<CouponSection />", () => {
  it("displays the right coupon amount, () => { /* ... */ });
});

describe("<PointSection />", () => {
  it("displays the calculated points", () => { /* ... */ });
});

describe("getAvailablePoint()", () => {
  it("if enough points: order amount - coupon amount", () => { /* ... */ });
  it("if not enough points: current points", () => { /* ... */ });
});
Enter fullscreen mode Exit fullscreen mode

One of the advantages of integration tests is that we can find **the side effects from altering the test code **relatively easily. Complex component tests, on the other hand, usually depend on external modules, so they can be harder to spot the side effects. For example, if we were to change the test code for getAvailablePoints function, and something goes wrong with this, then other tests that use this function may get affected too.

Be loyal to the goal of the tests

We've been through some aspects of the integration test. This kind of tests is ideal for verifying the correct functioning of one or more components together. They also help find the possible side effects and where they occured.

However, having just one type of tests may not be ideal for testing the entire application. It is important to identify the testing strategies for each module or layer within the project. For this, I recommend to try out the different types of tests (unit tests, E2E, integration..) for each case. Don't forget the end goal of testing is to ensure the quality of the application. What's the best testing method for each situation is the question we should have in mind.

Top comments (0)