Skip to main content

Practical Test Example

In this section we're going to go through a practical example of a test for a new piece of work in order to show what unit/integration tests might look like.

The feature

To continue the bonus offer example, as part of some work to improve the bonus offers flow for users, we've been tasked with creating a new screen where a user can enter and redeem a bonus offer code. It contains a title, an input field, and a submit button. It also shows them the number of offers they've redeemed at the bottom of the screen. The acceptance criteria in the ticket is:

  • As a user I should be able to enter my bonus offer code and redeem it.
  • As a user I should be able to view the details of the bonus offer before I redeem it.
  • As a user I should be shown a success message when an offer is successfully redeemed.
  • As a user I should be shown an error message that tells me the offer code is invalid if the code is invalid.
  • As a user I should be shown a generic error message if the redemption fails for any other reason.
  • As a user I should see a count of the number of bonus offers I have available on my account.

What do we want to achieve with our tests?

Going off this acceptance critera, we have clear and well defined expectations of our new feature. We want to write tests that:

  1. Validate that our code is working as expected as per the acceptance criteria.
  2. Give us the confidence that us our code changes and the codebase evolves, our feature continues to work as defined by the acceptance criteria.

We can accomplish this by writing tests that perform those tasks and let us know if the expected outcomes have been reached.

Starting with the test cases

Because our acceptance criteria is already explicit, we know what the behaviour of our code should be. This is a great use case for test driven development, so we can start with writing some test cases for our screen, which will be an integration test:

describe('Bonus offer redemption screen', () => {
it('Allows the user to redeem a bonus offer code.', () => {});
it('Shows the user a success message when an offer code is successfully redeemed.', () => {});
it('Shows the user an invalid code message when an offer code is invalid.', () => {});
it('Shows the user a generic error code message when the redemption fails.', () => {});
it('Shows the user the number of bonus offers they have available on their account.', () => {});
})

Here we've layed out a number of test cases for our new feature in the form of an integration test. These tests will cover the whole feature flow, as well as each individual component used to build the screen.

Our E2E tests will more or less match these.

Integration or unit tests?

Say in this example we've built our feature, and our folder structure looks like this:

app
├── shared
│ └── components
│ ├── Input.tsx // generic input component used across the app
│ ├── Input.test.tsx
│ ├── Button.tsx // generic button component used across the app
│ └── Button.test.tsx
└── features
└── bonus-offers
├── screens
│ └── RedeemCodeScreen.tsx
└── components // each of these are single use and feature specific
├── BonusCodeInput.tsx
├── SubmitBonusButton.tsx
└── SubmissionMessage.tsx

Given our setup it's clear that we would get the most value in writing a test for RedeemCodeScreen. That's because it would cover tests for each of the components (those in bonus-offers/components) it's built with while also testing the integration of those components and overall flow. This makes it an integration test, which combined with the benefits mentioned, would also save us time writing unit tests for components that don't really need it.

So we don't test BonusCodeInput/SubmitBonusButton/SubmissionMessage?

No, we don't need to. If any of them break then our tests for RedeemCodeScreen will fail. By writing them we're needlessly doubling up on coverage which slows down our suite, and creates tech debt.

Setting up the tests

So we've built our hot new feature, and it's time to make sure it's tested. We have our test cases written, we now need to fill them out.

// Not actual working code, just an example of how/what we're testing

// we mock the store so we can turn the feature on and set the necessary state
const mockStore = configureMockStore()({
...store.getState(),
client: {
offers: {
availableCount: 2
}
},
config: {
domain: {
features: {
['bonus-offer-v2']: true,
},
},
},
});

// Mocking the API call sent by our screen
const { server, rest } = setupMSW();
server.use(rest.post(BONUS_REDEMPTION_URL, (_, res, ctx) => res(ctx.json({ offer: MOCK_OFFER_RESPONSE }))))
server.listen()

const OFFER_CODE = "AUTOWINALLMULTIS69"
const INVALID_OFFER_CODE = "INVALIDOFFER"

const testWrapper = (flag: string, element: JSX.Element) => {
return render(
<Provider store={mockStore}>
<RedeemCodeScreen />
</Provider>
);
};

describe('Bonus offer redemption screen', () => {
it('Allows the user to redeem an offer, showing a success message when offer code is successfully redeemed.', () => {
render(<OfferRedemptionScreen />);

fireEvent.changeText(screen.getByPlaceholderText('Bonus code'), OFFER_CODE);
fireEvent.press(screen.getByText('Redeem Code'));

expect(screen.getByText('Success!')).toBeTruthy();
});

it('Shows the user an invalid code message when an offer code is invalid.', () => {
// mock API returning invalid

render(<OfferRedemptionScreen />);

fireEvent.changeText(screen.getByPlaceholderText('Bonus code'), INVALID_OFFER_CODE);
fireEvent.press(screen.getByText('Redeem Code'));

expect(screen.getByText('Offer code is invalid.')).toBeTruthy();
});

it('Shows the user a generic error code message when the redemption fails.', () => {
// mock API returning error

render(<OfferRedemptionScreen />);

fireEvent.changeText(screen.getByPlaceholderText('Bonus code'), OFFER_CODE);
fireEvent.press(screen.getByText('Redeem Code'));

expect(screen.getByText('Something went wrong.')).toBeTruthy();
});

it('Shows the user an updated number of bonus offers available on their account after successful redemption.', () => {
render(<OfferRedemptionScreen />);

fireEvent.changeText(screen.getByPlaceholderText('Bonus code'), OFFER_CODE);
fireEvent.press(screen.getByText('Redeem Code'));

expect(screen.getByText('You have 3 bonus offers available')).toBeTruthy()
});
})

Here our tests are:

  • Verifying expected behaviour based off of the acceptance criteria.
  • Ensuring our components (e.g. input component, submit button, etc) are all working, removing the need for them to be unit tested.
  • Documenting this part of the codebase, meaning any newcomers can read our tests and understand what the code is meant to be doing.
  • Giving us overall confidence that our feature is working as expected.

Our current implementation stores the number of offers available on the user's account by making a request to the API, and storing the response in redux. We could hypothetically refactor that to use Tanstack Query instead, and because we aren't testing implementation details our tests should work so long as our refactor was successful, making the refactor easier and less stressful.