Your submission was sent successfully! Close

You have successfully unsubscribed! Close

Thank you for signing up for our newsletter!
In these regular emails you will find the latest updates about Ubuntu and upcoming events where you can meet our team.Close

Testing your user contract

Jeff Pihach

on 10 February 2020

This article is more than 4 years old.


Whenever you write any code that is to be consumed by another, whether it be a library or some UI element, that consumer expects it to work in a certain way every time they interact with it. All good developers would agree and that’s why we also write tests that either break our code up into chunks and test that each chunk works as expected, unit tests, or test the entire lifecycle, end to end tests.

Anyone who has written unit tests for long enough knows that they are tedious to keep in sync with refactors and often end up taking a disproportionate amount of time compared to the time it took to write the functional code. I propose that that we focus less on unit tests and replace them with what I’m calling the user contract of your code.

What is a user contract?

The consumer expects that when they perform action X, they receive outcome Y. Typically they are not concerned about how X became Y just that it does so reliably. This is what I’m calling the user contract. If we as the authors of the code take the same view from a testing perspective, it allows us to write simpler tests and gives us the ability to refactor how a library or UI component works without having to update our tests, dramatically speeding up refactoring.

While these examples are written in JavaScript the same techniques apply to all languages.

Library example

Starting with a simple library that another developer may be using…

export async function fetchUserList() {
  const userList = await _queryDBForUserList();
  return await _formatUserList(userList);
}

function _queryDBForUserList() {
  // Fetch content from the database.
}

function _formatuserList() {
  // Reformat the data as returned from the database.
}

A consumer of this API would have a couple expectations:

  • That function is asynchronous.
  • It returns a user list in a specific structure.

These expectations then outline what your tests are:

describe('fetchUserList', () => {
  it('does not block');
  it('returns in the correct format');
});

You should note that we don’t test any method that wasn’t exported, nor do we export methods simply for testing purposes.

To aid the user in understanding what this contract is you can outline it in the docblock for the exported function. This way it can be used to generate the documentation for your library and help outline what your test structure is.

/**
  Returns a formatted user list.
  @return {Object} The user list in the following format:
  { id: INT, name: STRING, favouriteColour: STRING }
*/
export async function fetchUserList() {
  const userList = await _queryDBForUserList();
  return await _formatUserList();
}

We don’t explicitly test the _queryFBForUSerList and _formatUserList functions as they are implementation details. If you were to change the type of database returning the user list, or the algorithm being used to format the user list you should not have to also modify your tests as the contract to your users has not changed. They still expect that if they call fetchUserList they will receive the list in the specified format.

UI Example

Let’s take a look at a UI component this time using the React javascript library, in an effort to save space I’ve removed the functions that aren’t exported. This also helps to illustrate their irrelevance in our testing strategy.

export const LogIn = ({ children }) => {
  const userIsLoggedIn = useSelector(isLoggedIn);
  const userIsConnecting = useSelector(isConnecting);

  const button = _generateButton(userIsConnecting);

  if (!userIsLoggedIn) {
    return (
      <>
        <div className="login">
          <img className="login__logo" src={logo} alt="logo" />
          {button}
        </div>
        <main>{children}</main>
      </>
    );
  }
  return children;
};

This is a fairly simple component that renders a login button with a logo. Let’s go through the exercise and see what our User Contract is:

  • If the user is not logged in
    • It renders a logo and button to log in.
    • It renders any children passed to it.
    • clicking the button logs in.
  • If the user is logged in
    • It does not render a logo and button.
    • It renders any children passed to it.

Our tests would be:

describe('LogIn', () => {
  describe('the user is not logged in', () => {
    it('renders a logo and button to log in');
    it('renders any children passed to it');
    it('clicking the button logs in');
  });
  describe('the user is logged in', () => {
    it('does not render a logo and button to log in');
    it('renders any children passed to it');
  });
});

Testing the returned value in a UI component is a little more nuanced than checking a return value of a library function. We don’t necessarily want to check every specific detail of each element returned unless it’s part of the contract. I’ll expand these tests with assertions but eschew the component setup and rendering in interest of space.

it('renders a logo and button to log in', () => {
  expect(wrapper.find('.login__logo').length).toBe(1);
  expect(wrapper.find('.login button').length).toBe(1);
);
it('renders any children passed to it', () => {
  expect(wrapper.find('main .items').length).toBe(3);
});
it('logs the user in', () => {
  wrapper.find('.login button').simulate('click', {});
  expect(useSelector(isLoggedIn)).toBe(true);
});

It’s important to note here that we have tried to limit the specific details that aren’t relevant to the contract of the component. This allows the design to change and the contract to remain valid and we do not need to update the tests. This is especially beneficial when you have a shared component library within your company. You can update the designs and implementation details of your components without updating the tests.

What if I…

  • …want to test that an api call is being made with the correct signature?
    • You should consider moving this sub api call into its own library and having it tested there. You’ll then be testing the user contract of this new library, that a call to your api is making a specific call to another api.
  • …have to mock out an api call?
    • You’ll find value in implementing a system that mocks out the layer which returns the data. In this layer it’ll return a pre-defined set of data depending on the arguments it receives. This way, if the arguments ever change and become invalid, it’ll no longer return the correctly formatted outcome and your tests will fail.
  • …have a complex algorithm that needs testing?
    • Consider moving this to a separate library and test it separately so that the user contract of that library is being tested.
  • …want to change the contract?

Conclusion

When writing the code and exporting methods, ask yourself if the user needs to have access to this method or if you’re only doing it for testing purposes. You can always export more methods, you can’t always take exported methods away.

When writing tests ask yourself how can the consumer interact with your code and what type of outcome is expected for those interactions and them make sure you have those documented and assertions in your tests.

Don’t test implementation details of an exported method or UI component. Consider moving those to a different user contract if you feel they need direct testing.

This post originally appeared on: https://fromanegg.com/post/2020/01/01/testing-your-user-contract/

Talk to us today

Interested in running Ubuntu in your organisation?

Newsletter signup

Get the latest Ubuntu news and updates in your inbox.

By submitting this form, I confirm that I have read and agree to Canonical's Privacy Policy.

Related posts

Let’s talk open design

Why aren’t there more design contributions in open source? Help us find out!

Crafting new Linux schedulers with sched-ext, Rust and Ubuntu

In our ongoing exploration of Rust and Ubuntu, we delve into an experimental kernel project that leverages these technologies to create new schedulers for...

Canonical’s recipe for High Performance Computing

In essence, High Performance Computing (HPC) is quite simple. Speed and scale. In practice, the concept is quite complex and hard to achieve. It is not...