Testing your user contract

  • in 未分类
  • by
  • 二月 12, 2020
  • Testing your user contract已关闭评论

Ubuntu is available in Cloud Server Linux. Contact us to find out our latest offers!

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/

Ubuntu is available in Cloud Server Linux. Contact us to find out our latest offers!

Comments are closed.