Testing your user contract
Jeff Pihach
on 10 February 2020
Tags: Design , Development , testing
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?
- You can release a major version bump of your code. See how to use the semver versioning system.
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
Related posts
Designing Canonical’s Figma libraries for performance and structure
How Canonical’s Design team rebuilt their Figma libraries, with practical guidelines on structure, performance, and maintenance processes.
Visual Testing: GitHub Actions Migration & Test Optimisation
What is Visual Testing? Visual testing analyses the visual appearance of a user interface. Snapshots of pages are taken to create a “baseline”, or the current...
Let’s talk open design
Why aren’t there more design contributions in open source? Help us find out!