artisanal bytes

“Hand-crafted in San Francisco from locally sourced bits.”

Future-Proof Testing

Several months ago, I wrote about the value of testing and specifically the value of testing well. In that post, I introduced the idea of future-proof tests – tests that will prevent a future change from breaking functionality without breaking a test. But I failed to give an example. A scant half-year later and I have one for you.

But first, I feel I must bore you a bit with my philosophy on testing to give some context. I am not a test-driven-development zealot; in fact, I’m not a zealot of any kind. I like to use tools that add value to my process, and I think that tests, when written well, add a tremendous amount of value. The emphasis is on “when written well,” because as I explain in my previous post, if the tests are not valuable, then they are actually detrimental to the development process. Tests should be written to validate that the software we write is doing what we want it to do. They should be written from the perspective of a user. If the user is another piece of software, test the API; and for an actual end-user, test the end-to-end experience. If you think about tests from that perspective, then you will be testing the functionality to guarantee that it is behaving correctly and help detect when behavior drifts.

The idea of a future-proof test is to make life easier for the next engineer who comes to change your code. If you have written a test that does not break when another engineer changes functionality incorrectly, then you have a fragile test. A future-proof test doesn’t go down without a fight. Here’s an example.

Cloning Objects

Often times we end up writing code to clone an object, copying the data, field by field, from the origin object to a new object. In Javascript, that may look like this:

const Book = {};
Book.create = function create(spec) {
  const {
    title,
    author
  } = spec;

  function clone() {
    return create({
      title,
      author
    });
  }

  return Object.freeze({
    title,
    author,

    clone
  });
};

Now envision a test for the clone function. The first thing that pops to mind is probably something like:

tap.test('Book.clone', function (test) {
  var a = Book.create({
    title: 'Test Driven Development',
    author: 'Kent Beck'
  });
  var b = a.clone();
  test.equal(b.title, a.title, 'Missing title');
  test.equal(b.author, a.author, 'Missing author');
  test.end();
});

That is a totally valid test. It has 100% coverage of the code, and it correctly tests that the function does what we want. Success!

But, what happens if another engineer wants to add a new field to Book and forgets to add that field to the clone function? The test still passes, but it no longer properly guarantees our functionality, because clone is now broken.

const Book = {};
Book.create = function create(spec) {
  const {
    title,
    author,
    genre     // New field
  } = spec;

  function clone() {
    return create({
      title,
      author  // Whoops, forgot to add `genre`
    });
  }

  return Object.freeze({
    title,
    author,
    genre,    // New field

    clone
  });
};

Imagine if we had written a future-proof test for clone. It might look like this:

tap.test('Book.clone', function (test) {
  var proto = Book.create({});
  // Create a spec based on the fields of `proto`
  // with random data in each field.
  var spec = 
    Object.keys(proto)
      .reduce((spec, key) => {
        if (typeof proto[key] !== 'function') {
          spec[key] =
            Math.random()
              .toString(36).replace(/[^a-z]+/g, '');
        }
        return spec;
      }, {});

  var a = Book.create(spec);
  var b = a.clone();
  Object.keys(a).forEach((key) => {
    if (typeof a[key] !== 'function') {
      test.equal(b[key], a[key],
        `Key '${key}' not cloned properly`);
    }
  });
  test.end();
});

Any new field that is added to the object that is not propagated through a clone will result in a failed test with an error message of “Key '<key name>' not cloned properly.” That will help remind the next engineer to add that new field to the clone function.

This is a fairly simple example, but I leave it to you to find places in your code that can be tested while thinking about future failures and how to prevent them. If you do, your code will be better, because your tests will be better.

Trade-offs

I like to say that engineering is a discipline of making trade-offs. This is a shining example of one of those trade-offs. The first test is much easier to read and understand. The next engineer who comes to work on the code will be able to add the correct new test case and carry on her merry way. It is not clever at all, but clever is not always better.

When you are writing these tests you have to decide for yourself if it is worth the extra overhead of writing code that may be harder to extend in the future. I cannot answer that for you. I do know that the future-proof test can be made more future-proof itself by adding more comments so that the next engineer won’t have to spend too much time figuring out what it does.

Here’s another go at the test with more comments.

// Check that `clone` copies all of the fields from
// the original object into the new object.
tap.test('Book.clone', function (test) {
  // `proto` will have all of the accessible fields
  // in it as keys. Use that information to create
  // a new "spec" object with random data in
  // each field.
  var proto = Book.create({});
  var spec =
    Object.keys(proto)
      .reduce((spec, key) => {
        // We want to create a new random value for
        // all fields of the prototype object that
        // are not functions.  This assumes all
        // fields are strings, but we could add new
        // `typeof` conditionals to create random
        // data of other types as well.
        // We could even supply specific functions
        // for fields if we expected `clone` to
        // copy methods, too.
        if (typeof proto[key] !== 'function') {
          spec[key] =
            Math.random()
              .toString(36).replace(/[^a-z]+/g, '');
        }
        return spec;
      }, {});

  var a = Book.create(spec);
  var b = a.clone();
  // Check that every field in the original object
  // `a` has been copied into the cloned object `b`.
  Object.keys(a).forEach((key) => {
    if (typeof a[key] !== 'function') {
      test.equal(b[key], a[key],
        `Key '${key}' not cloned properly`);
    }
  });
  test.end();
});

Is this better?


Special thanks to Jim Posen for offering feedback on the trade-offs of the future-proof test.