Using State Tables for Testing

Tests can benefit a project in many different ways. For example, they help ensure that the software behaves as expected. They also help document that functionality for pieces of code that other developers may have to maintain.

Lately, I’ve been using state tables in my tests to improve both of these benefits. State tables allow for clear and comprehensive tests that are scalable during the development.

Tests without State Tables

First, let’s look at what happens when you test without a state table. Imagine we have a list of products and a set of buttons that appear depending on which products are selected. If no products are selected, we want to show an “Add Product” button. If one product is selected, we want to show an “Edit Product” button and a “Remove Product” button. Finally, if more than one product is selected, we want to show only the “Remove” button.

Say we implemented this logic in a function called “determineProductListButtonVisiblity” which takes a list of selected products and returns an object describing which buttons are visible. A unit test for no products selected would look something like this:


it('shows the add button when no products are selected', () => {
  const selectedProducts = [];

  const expectedResults = {
    showAddButton: true,
    showEditButton: false,
    showRemoveButton: false,
  };
  const actualResults = determineProductListButtonVisibility(selectedProducts);

  expect(actualResults).to.deep.equal(expectedResults);
});

This test is simple, and it clearly describes what happens when no products are selected. We can copy the structure of this test when one product is selected:


it('shows the edit and remove button when one product is selected', () => {
  const selectedProducts = ['product1'];

  const expectedResults = {
    showAddButton: false,
    showEditButton: true,
    showRemoveButton: true,
  };
  const actualResults = determineProductListButtonVisibility(selectedProducts);

  expect(actualResults).to.deep.equal(expectedResults);
});

And again for cases where more than one product is selected:


it('shows the edit and remove button when one product is selected', () => {
  const selectedProducts = ['product1', 'product2'];

  const expectedResults = {
    showAddButton: false,
    showEditButton: false,
    showRemoveButton: true,
  };
  const actualResults = determineProductListButtonVisibility(selectedProducts);

  expect(actualResults).to.deep.equal(expectedResults);
});

A Single Test with a State Table

Sure, these tests accurately test the function and help document its behavior. However, we just wrote three nearly identical tests. Using a state table can test this function just as well, but a little more concisely.

Here’s an example of what a state table might look like in this situation:


it('determines the visibility of the buttons for the product list', () => {
  const stateTable = [
  // Product1 Product2 Add Edit Remove
    [false, false, true, false, false],
    [true, false, false, true, true],
    [true, true, false, false, true],
  ];

  for (const testCase of stateTable) {
    const expectedResults = {
      showAddButton: testCase[2],
      showEditButton: testCase[3],
      showRemoveButton: testCase[4],
    };

    const selectedProducts = testCase.slice(0, 2).reduce((accumulator, isSelected, index) => {
      if (isSelected) {
        accumulator.push(`product${index}`);
      }
      return accumulator;
    }, []);

    const actualResults = determineProductListButtonVisibility(selectedProducts);

    expect(actualResults, ).to.deep.equal(expectedResults);
  }
});

By using this state table instead of multiple tests for each test case, we gain a few benefits:

  • The behavior of the function is found in one spot, not across multiple tests. It’s simpler for a developer to go to this single test and understand the outcomes than to scan a suite of tests for the one function.
  • It’s easy to add test cases. Instead of creating a new test for additional edge cases, a developer can just add a single line to this test.
  • The test is likely more comprehensive. With all of the test cases in a single spot, it’s easier to see what cases might be missing.
  • The test is scalable. New functionality can be tested by adding another row or column. If we were to add a “Duplicate” button in this example, we would just need to add a column to the state table and decide on a case-by-case basis when that button should be shown. Without the state table, we’d have to track down which tests should care about this new “Duplicate” button.

Using state tables for testing is great for situations where there are a variety of inputs for a function that produce different results. If you find yourself copying a test case over and over, try simplifying this process with a state table.