Testing Hooks - Hooks for code organization

3 min read

This is part one in a series on Testing Hooks

The first category of hook usage is code organization to make our code easier to read. This is usually in the form of breaking a component into smaller parts, just like we might do with a regular non-React function.

// Original Function
export function calculateBillingTotal(team) {
  return team.reduce((teamTotal, { addons, excededUsageCost, planBaseCost }) => {
    const addonCost = addons.reduce((addonTotal, { addonBaseCost, addonExtrasCost }) => {
      return addonTotal + addonBaseCost + addonExtrasCost
    }, 0)

    return teamTotal + excededUsageCost + planBaseCost + addonCost
  }, 0)
}

// Broken into smaller parts
export function calculateBillingTotal(team) {
  return team.reduce((teamTotal, { addons, excededUsageCost, planBaseCost }) => {
    return teamTotal + excededUsageCost + planBaseCost + calculateAddonsBillingTotal(addons)
  }, 0)
}

function calculateAddonsBillingTotal(addons) {
  return addons.reduce((addonTotal, { addonBaseCost, addonExtrasCost }) => {
    return addonTotal + addonBaseCost + addonExtrasCost
  }, 0)
}

Readability is subjective, but you get the point. We can break a component into smaller parts with hooks the same way we did above with a function being broken into smaller functions.

To explore this idea with hooks let's look at this <Option /> component, which is part of a compound component.

/**
 * https://github.com/Samic8/robust-ui-examples/blob/f11fc66565cc241d37f422451d4697dc1c29f4dd/src/components/hooks/included-logic/Option.js
 */
import React, { useContext } from "react";
import { MultiselectContext } from "./MutliselectContext";

export default function Option({ title = "", value }) {
  const { onSelectOption, selectedValues, searchText } = useContext(
    MultiselectContext
  );
  const isOptionSelected = selectedValues.includes(value);
  const isHidden = searchText && !title.includes(searchText);

  if (isHidden) {
    return null;
  }

  return (
    <li>
      <label>
        <input
          type="checkbox"
          checked={isOptionSelected}
          onChange={() => onSelectOption(value)}
        />
        {title}
      </label>
    </li>
  );
}

This component is small but for the point of this chapter let's pretend there was a lot of related logic that we could abstract into a hook to improve readability. We could create a private hook to group this logic -- it's private because it's within the same file and not exported and used elsewhere.

/**
 * https://github.com/Samic8/robust-ui-examples/blob/f11fc66565cc241d37f422451d4697dc1c29f4dd/src/components/hooks/abstracted-private-logic/Option.js
 */
import React, { useContext } from "react";
import { MultiselectContext } from "./MutliselectContext";

export default function Option({ title = "", value }) {
  const { onSelectOption, isOptionSelected, isHidden } = useMultiselectOption({
    title,
    value,
  });

  if (isHidden) {
    return null;
  }

  return (
    <li>
      <label>
        <input
          type="checkbox"
          checked={isOptionSelected}
          onChange={() => onSelectOption(value)}
        />
        {title}
      </label>
    </li>
  );
}

function useMultiselectOption({ title, value }) {
  const { onSelectOption, selectedValues, searchText } = useContext(
    MultiselectContext
  );
  const isOptionSelected = selectedValues.includes(value);
  const isHidden = searchText && !title.includes(searchText);

  return {
    onSelectOption,
    searchText,
    isOptionSelected,
    isHidden,
  };
}

For this category of hook, it's most effective to write tests in a way where the tests don't have any knowledge that the hook exists.

/**
 * https://github.com/Samic8/robust-ui-examples/blob/f11fc66565cc241d37f422451d4697dc1c29f4dd/src/components/hooks/abstracted-private-logic/Multiselect.test.js
 */
import Multiselect from "./Multiselect";
import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Option from "./Option";
import Search from "./Search";
import List from "./List";

describe("<Multiselect />", () => {
  const onSelectedValueChange = jest.fn();

  afterEach(jest.clearAllMocks);

  beforeEach(() => {
    render(
      <Multiselect
        selectedValues={[6, 5]}
        onSelectedValueChange={onSelectedValueChange}
      >
        <Search />
        <List>
          <Option title="Six" value={6} />
          <Option title="Five" value={5} />
          <Option title="Nine" value={9} />
        </List>
      </Multiselect>
    );
  });

  describe("given an option is already selected", () => {
    const title = "Six";

    describe("when that option is clicked", () => {
      beforeEach(() => userEvent.click(screen.getByText(title)));

      it("then should exclude the option from the selected values", () => {
        expect(onSelectedValueChange).toHaveBeenCalledWith([5]);
      });
    });
  });
  // ...
});

When our tests are written like this it allows us to change the implementation without changing the tests. Both the example without the hook and with the hook will pass the same tests. So when another developer or our future self decides that private hooks are blasphemy, we can refactor without having to make changes to the tests and we will still be confident that the component works as expected.

Continue to Part 2

Was this article helpful?