Personal development notes and experiments

Learning to (kind of) enjoy test driven development in JS with simpler tests

I have never really used test driven development. When I tried, it got too clumsy, messy, I tried testing methods of my classes. Didn't really know what I was doing.

The light came after attempting to TDD a functional component wish useState / useEffect hooks when they were released. No longer could I access state or internal methods of a class in my tests. Not without keeping them outside the component I then knew this is not how it was intended to be done.

Along came the (https://testing-library.com/)[https://testing-library.com/] with it's guildelines about how one should test components exactly the way a user would use them.

This means always rendering them to a virtual DOM, accessing elements by their DOM attributes, manipulating them and asserting based on DOM state. You just have no way to do more anymore. And that is pretty liberating in a sense that you can just focus on writing a decent test for the real use case.

An additonal benefit is the encouragment to drop the long standing "1 assert per 1 test" rule (it's in the FAQ). The tooling is capable to show you exactly on which line the test fails while also outputing color coded "Expected vs Reality" DOM state. So it's just no longer useful to go through the trouble of writing more tests. Test a success case, test a failure case and that might just do it.

The old and the new ways

If there is a component involving:

  1. Show "Loading..." text
  2. Fetch data A from endpoint a
  3. Fetch data B from endpoint b
  4. Process A using some Array.reduce + Array.filter etc.
  5. Process B using some Array.reduce + Array.filter etc.
  6. Merge processed data using some procedure only used in that component
  7. Display (correctly) merged data in a list

How I used to test it

I write 7 or more tests for each step. If this was a functional component, I would moved the algorithms in 4, 5, 6 outside the component and test them separately.

This isn't ideal, because this is a component, not some helper library. If requirements change, algoriths processing the data will change. 4, 5, 6, 7 will all get broken. Or, what if we use new library which performs exactly what 4, 5 used to do. We can't refactor 4 and 5 out without breaking the tests.

However, as I mentioned, this is a component. All we care about is what the user sees is correct, as defined by the task, right?

How I'm learning to do it now

I write one longer test. In that test I check 1 and 7. Possibly, I also check if 2, 3 calls API only once, because are quite suitable for nasty bugs involving render loops.

Does this break TDD if I don't test each step?

Initially, I thought of it that way. How do I "write a small test and make it pass" If I don't write a test. I mean, we all know the mantra:

  1. Split task into smallest logical parts
  2. Write a test for that small part
  3. Make it pass
  4. Repeat

Then refactor to make it nice, make it fast. So how do I write the logic without testing it? Well, the key point for me was noticing that mostly, just as I said, the 'logic' is just a few 'reduce' + 'filter' statements. Perfectly acceptable to write using just one test checking the final result.

To tell you the truth, I didn't yet TDD a really big component (this is typically a large form with one input affecting value of others after calling API first). However I feel now I'll just be forced to split it further. And, if there's a lot of conditional logic, it's still always possible to move it to functions outside the component and test them separately.

Any way, it's the first time I'm actually looking forward to it.

Excercise

Here's the test:

// Task:
// show "Loading..." text
// fetch data [b, a, a, a] from endpoint /lowercase
// fetch data [A, B, C, B, E] from endpoint /uppercase
//
// Join similar characters from both endpoints, display their counts sorted like this:
// - a: 4
// - b: 3
// - c
// - e: 1
//
// // hide "Loading..." text
// In case of unexpected error from any endpoint, show error message "Unexpected error".

// Wrote the tests based on the excellent example of testing-library:
// https://testing-library.com/docs/react-testing-library/example-intro

import React from "react";
import { rest } from "msw";
import { setupServer } from "msw/node";
import { render, fireEvent, waitFor, screen } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import Component from "./_component.jsx";

let lowercaseCallCount = 0;
let upperCaseCallCount = 0;
const server = setupServer(
rest.get("/lowercase", (req, res, ctx) => {
lowercaseCallCount++;
return res(ctx.json(["a", "b", "a", "c"]));
}),
rest.get("/uppercase", (req, res, ctx) => {
upperCaseCallCount++;
return res(ctx.json(["A", "D", "C", "F"]));
})
);

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

test("fetches lowercase and uppercase character arrays, renders total character counts", async () => {
render(<Component lowercaseUrl="/lowercase" uppercaseUrl="/uppercase" />);
expect(screen.getByText("Loading...")).toBeInTheDocument();
let options;
await waitFor(() => {
options = screen.getAllByTestId("char-select-option");
return options;
});
expect(options.length).toBe(6);
expect(options[0]).toHaveTextContent("Select char");
expect(options[0].selected).toBeTruthy();
expect(options[1]).toHaveTextContent("a: 3");
expect(options[2]).toHaveTextContent("b: 1");
expect(options[3]).toHaveTextContent("c: 2");
expect(options[4]).toHaveTextContent("d: 1");
expect(options[5]).toHaveTextContent("f: 1");

expect(options[0]).toHaveValue("");
expect(options[1]).toHaveValue("a");
expect(options[2]).toHaveValue("b");
expect(options[3]).toHaveValue("c");
expect(options[4]).toHaveValue("d");
expect(options[5]).toHaveValue("f");

fireEvent.change(screen.getByTestId("select"), { target: { value: "b" } });
expect(options[0].selected).toBeFalsy();
expect(options[2].selected).toBeTruthy();

expect(lowercaseCallCount).toBe(1);
expect(upperCaseCallCount).toBe(1);
expect(screen.queryByText("Loading...")).toBeNull();
expect(screen.getByText("Next")).toBeInTheDocument();
});

test("renders error message if unexpected fetch error occurs", async () => {
server.use(
rest.get("/lowercase", (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<Component lowercaseUrl="/lowercase" uppercaseUrl="/uppercase" />);
expect(screen.getByText("Loading...")).toBeInTheDocument();
await waitFor(() => screen.getByText("Unexpected error"));
expect(screen.queryByText("Loading...")).toBeNull();
});

And here's the function:

import React, { useEffect, useReducer, useState } from "react";
import axios from "axios";

const initialState = {
charCounts: null,
error: null,
isLoading: true,
};

const charSelectReducer = (state, action) => {
switch (action.type) {
case "SUCCESS":
return {
charCounts: action.charCounts,
error: null,
isLoading: false,
};
case "ERROR":
return {
charCounts: null,
error: action.error,
isLoading: false,
};
default:
return state;
}
};

const CharSelect = ({ lowercaseUrl, uppercaseUrl }) => {
const [value, setValue] = useState("");
const [{ charCounts, error, isLoading }, dispatch] = useReducer(
charSelectReducer,
initialState
);

useEffect(() => {
const { token: cancelToken, cancel } = axios.CancelToken.source();
(async () => {
try {
const [{ data: lowercase }, { data: uppercase }] = await Promise.all([
axios.get(lowercaseUrl, { cancelToken }),
axios.get(uppercaseUrl, { cancelToken }),
]);
const charCounts = lowercase
.concat(uppercase)
.sort((a, b) => a > b)
.reduce((acc, char) => {
const lowChar = char.toLowerCase();
acc[lowChar] = ++acc[lowChar] || 1;
return acc;
}, {});
dispatch({ type: "SUCCESS", charCounts });
} catch (e) {
dispatch({ type: "ERROR", error: "Unexpected error" });
}
})();

return () => {
cancel();
};
}, [axios]);

if (isLoading) return "Loading...";
if (error) return error;
return (
<>
<select data-testid="select" onChange={(e) => setValue(e.target.value)} value={value}>
<option value="" data-testid="char-select-option">
Select char
</option>
{Object.keys(charCounts).map((char) => (
<option key={char} value={char} data-testid="char-select-option">
{char}: {charCounts[char]}
</option>
))}
</select>
<button>Next</button>
</>
);
};

export default CharSelect;