{name:"craig",surname:"white"}

A different way to approach react testing library tests

When writing unit tests we can sometimes get into the rhythm, accept the tooling available and get the ticket across the line and move on to the next, and we'll end up with something that looks like this (try to get a feel for what's shown here):

import { mocked } from 'ts-jest/utils'; import { fireEvent. render } from '@testing-library/react'; import { RegisterForm } from '@modules/Login'; import { ThemeProvider } from '@providers/Theme'; import { signIn } from '@handlers/auth'; jest.mock('@handlers/auth'); const mockSignIn = mocked(signUp, true); describe(LoginForm.name, () => { beforeEach(() => { jest.resetAllMocks(); }) it('should call signUp if valid details are passed in', () => { const { getByTestId } = render( <ThemeProvider> <RegisterForm /> </ThemeProvider> ) const firstName = getByTestId('register-form-input-name'); const surname = getByTestId('register-form-input-surname'); const password = getByTestId('register-form-input-password'); const description = getByTestId('register-form-input-description'); const submitButton = getByTestId('register-form-button-submit'); const input = { name: 'Joe', surname: 'Rogan', password: '31km347m4k35u57r0n93r', description: 'Podcast Host' } fireEvent.change(firstName, { target { value: input.name }}); fireEvent.change(surname, { target { value: input.surname }}); fireEvent.change(password, { target { value: inupt.password }}); fireEvent.change(description, { target { value: input.description }}); fireEvent.click(submitButton); expect(mockSignIn).toBeCalledTimes(1); expect(mockSignIn).toBeCalledWith(input); }); it('should not call signUp if invalid details are passed in', () => { const { getByTestId } = render( <ThemeProvider> <RegisterForm /> </ThemeProvider> ) const firstName = getByTestId('register-form-input-name'); const surname = getByTestId('register-form-input-surname'); const password = getByTestId('register-form-input-password'); const description = getByTestId('register-form-input-description'); const submitButton = getByTestId('register-form-button-submit'); const input = { name: '127.0.0.1', surname: 'DROP TABLE;', password: 'H4k3rm4n', description: 'Luna 4 ever' } fireEvent.change(firstName, { target { value: input.name }}); fireEvent.change(surname, { target { value: input.surname }}); fireEvent.change(password, { target { value: inupt.password }}); fireEvent.change(description, { target { value: input.description }}); fireEvent.click(submitButton); expect(mockSignIn).toBeCalledTimes(0); expect(mockSignIn).not.toBeCalledWith(input); }); it('should not call signUp if empty fields are submitted', () => { const { getByTestId } = render( <ThemeProvider> <RegisterForm /> </ThemeProvider> ) const firstName = getByTestId('register-form-input-name'); const firstNameError = getByTestId('register-form-input-name-err'); const surname = getByTestId('register-form-input-surname'); const password = getByTestId('register-form-input-password'); const description = getByTestId('register-form-input-description'); const submitButton = getByTestId('register-form-button-submit'); const input = { name: '', surname: 'DROP TABLE;', password: 'H4k3rm4n', description: 'Luna 4 ever' } fireEvent.change(firstName, { target: { value: input.name }}); fireEvent.change(surname, { target: { value: input.surname }}); fireEvent.change(password, { target: { value: inupt.password }}); fireEvent.change(description, { target: { value: input.description }}); fireEvent.click(submitButton); expect(mockSignIn).toBeCalledTimes(0); expect(mockSignIn).not.toBeCalledWith(input); expect(firstNameError).toHaveTextContent('Hey! you forgot about me!'); }); // and so on and so on... - tried not to get too carried away. })

This isn't awful, but you can easily pull out a few issues with this, and there's definitely room to improve.

  • Lots of unnecessary repetition
  • Large test file with continued tests
  • Not the most readable code
  • Inclination to copy and paste and stick with the programme

So, now I've set the scene (hopefully), I'll introduce one of the concepts which helped put me in a mindset which helped relieve some of the raised issues.

Introducing PageObjects

I'm not going to act like I've discovered fire in this, more just raise the point that sometimes getting involved in other facets of your job can help bring forward new ways of thinking.

PageObjects are generally used in end to end testing, as a way to represent what is present on a page via code, where the name of the object will be the name of the page, the keys will be the name of the item on the page, and the value will be how you access that item (such as via a testId, css class or otherwise).

Lets us go back to the login example, but I've made a quick mockup to illustrate this.

We can think of this as the SignUp page, the page is composed of 4 inputs, a logo, a button and an ad, we can assume that these have the same testIds as were used in the previous example.

Now to illustrate this as a PageObject.

class SignUpPage { get name () { // $ is a way of accessing elements on a page via a locator. // "get" is a way to ensure the value is what you expect when you access it. return $('[data-testid="register-form-input-name"]'); } get surname () { return $('[data-testid="register-form-input-surname"]'); } get password () { return $('[data-testid="register-form-input-password"]'); } get description () { return $('[data-testid="register-form-input-description"]'); } get submitButton () { return $('[data-testid="register-form-button-submit"]'); } }

If you've worked with end to end packs such as WebdriverIO, protractor or selenium this should look pretty familiar, no matter the language - this is an example of a design pattern if you've not heard of this before.

You can see that this gives us a nice, repeatable way of defining what is accessible within a context and keeping the concern of this packaged neatly away from the complexities of test cases.

Some automation engineers I've worked with opt-in to include the actions inside the PageObject too, think actions as clicks, types, confirmations, etc...

Here's an example:

class SignUpPage { // ... get submitButton () { return $('[data-testid="register-form-button-submit"]'); } public clickSubmitButton () { this.submitButton.isClickable(); return this.submitButton.click(); } } const signUpPage = new SignUpPage(); export { signUpPage };

This would then be used in a context such as this:

// steps/Auth/SignUp import { signUpPage } from '@pageobjects/auth/SignUp'; import { Then } from '@cucumber'; Then('I click the submit button', () => { signUpPage.clickSubmitButton(); });

As you can probably tell from this, it's pretty straightforward, tidy, compact and fit-for-purpose, and if you're following should feel like an opportunity - as that's what hit me as I got more involved with automation testing, and still am learning.

So let's look at how we can apply what has just been shown back to our original context.

Introducing PageObjects, but inside React Testing Library

Let's initially draw some core parallels between what we just covered and the initial react-testing-library example.

React testing libraryE2E (End to end)
renderingrender(Element) functiongo to url (http://mywebsite.com)
getting elementsgetBy, queryBy...$, $$, xPath (framework dependent)
casescode drivenuser driven and NFR driven
connectivitymocks, spiesstubs, environment APIs

Now, with these core considerations in mind and the example from E2E testing let's make an example React Testing Library "PageObject" model for our RegisterPage test.

import { render, RenderResult } from '@testing-library/react'; import { RegisterForm, SignUpPageProps } from '@modules/Login'; import { ThemeProvider } from '@providers/Theme'; interface SignUpForm { name: string; surname: string; password: string; description?: string; } class SignUpPage { page: RenderResult; constructor (props?: SignUpPageProps) { this.page = render( <ThemeProvider> <SignUpPage {...props} > </ThemeProvider> ); } public name () { // You can possibly use get here but just for familiarity I just used a method // $ is a way of accessing elements on a page via a locator. // "get" is a way to ensure the value is what you expect when you access it. return this.page.getByTestId('register-form-input-name'); } public surname () { return this.page.getByTestId('register-form-input-surname'); } public password () { return this.page.getByTestId('register-form-input-password'); } public description () { return this.page.getByTestId('register-form-input-description'); } public getErrorField (field: keyof SignUpForm) { return this.page.getByTestId(`register-form-input-${field}-err`) } public submitButton () { const element = this.page.getByTestId('register-form-button-submit'); return { click: () => fireEvent.click(element), element: () => element; } } public fillForm (data: SignUpForm) { fireEvent.change(this.name, {target: {value: data.name}}); fireEvent.change(this.name, {target: {value: data.surname}}) fireEvent.change(this.name, {target: {value: data.password}}) fireEvent.change(this.name, {target: {value: data?.description}}) } } export {SignUpPage}; // Due to nature of page having to be re-rendered per test

As you can see it's very similar to the E2E example, with most of the differences being driven by the table above.

Result

import { mocked } from 'ts-jest/utils'; import { signIn } from '@handlers/auth'; jest.mock('@handlers/auth'); const mockSignIn = mocked(signUp, true); describe(LoginForm.name, () => { beforeEach(() => { jest.resetAllMocks(); }) it('should call signUp if valid details are passed in', () => { // const { getByTestId } = render( // <ThemeProvider> // <RegisterForm /> // </ThemeProvider> // ) // const firstName = getByTestId('register-form-input-name'); // const surname = getByTestId('register-form-input-surname'); // const password = getByTestId('register-form-input-password'); // const description = getByTestId('register-form-input-description'); // const submitButton = getByTestId('register-form-button-submit'); const signUpPage = new SignUpPage(); const input = { name: 'Joe', surname: 'Rogan', password: '31km347m4k35u57r0n93r', description: 'Podcast Host' } signUpPage.fillForm(input) signUpPage.submitButton().click(); expect(mockSignIn).toBeCalledTimes(1); expect(mockSignIn).toBeCalledWith(input); }); it('should not call signUp if invalid details are passed in', () => { // const { getByTestId } = render( // <ThemeProvider> // <RegisterForm /> // </ThemeProvider> // ) // const firstName = getByTestId('register-form-input-name'); // const surname = getByTestId('register-form-input-surname'); // const password = getByTestId('register-form-input-password'); // const description = getByTestId('register-form-input-description'); // const submitButton = getByTestId('register-form-button-submit'); const signUpPage = new SignUpPage(); const input = { name: '127.0.0.1', surname: 'DROP TABLE;', password: 'H4k3rm4n', description: 'Luna 4 ever' } signUpPage.fillForm(input) signUpPage.submitButton().click(); expect(mockSignIn).toBeCalledTimes(0); expect(mockSignIn).not.toBeCalledWith(input); }); it('should not call signUp if empty fields are submitted', () => { // const { getByTestId } = render( // <ThemeProvider> // <RegisterForm /> // </ThemeProvider> // ) // const firstName = getByTestId('register-form-input-name'); // const firstNameError = getByTestId('register-form-input-name-err'); // const surname = getByTestId('register-form-input-surname'); // const password = getByTestId('register-form-input-password'); // const description = getByTestId('register-form-input-description'); // const submitButton = getByTestId('register-form-button-submit'); const signUpPage = new SignUpPage(); const input = { name: '', surname: 'DROP TABLE;', password: 'H4k3rm4n', description: 'Luna 4 ever' } signUpPage.fillForm(input) signUpPage.submitButton().click(); expect(mockSignIn).toBeCalledTimes(0); expect(mockSignIn).not.toBeCalledWith(input); expect(signUpPage.getErrorField('name')).toHaveTextContent('Hey! you forgot about me!'); }); // and so on and so on... - tried not to get too carried away. })

So, reflecting back on the original issues with the first example:

  • Lots of unnecessary repetition - less so now.
  • Large test file with continued tests - reduced as the selection bits are packaged away.
  • Not the most readable code - I'd say it's more intuitive and less 'what does that method do'.
  • Inclination to copy and paste and stick with the programme - more of an inclination to experiment with the class and do more interesting tests.

In conclusion

So to wrap this up, hopefully, this article has drawn some light around the hidden fun in unit tests, that they don't have to be messy, they can be tidied up and pushed to follow the guidelines such as DRY and even possibly SOLID.

Hopefully, some other learnings from this are:

  • As an engineer, take some time to understand what QE's/QA's/Testers are doing - and conversely, as there's opportunity at the border between the two disciplines as they both strive for proven quality.
  • Unit test suites === Opportunity
  • Design patterns are interesting
  • What a page object is

Some other notes:

  • This doesn't have to be class-based, you can use functions just as easily.
  • You can apply this to any scope of frontend, whether it be component, module, page etc.., I'd suggest possibly thinking about how you could compose different scopes together with these objects to create some rich tests
  • Experiment with this and share
  • You can use jest lifecycle hooks in the page objects, so if you wanted to add mocks in there you can just as easily

Also since you made it this far I found an old example that I've pulled out of one of my old personal products page object gist

2022-10-27 - React
The more I work with automation engineers the more I start to appreciate the problem they are working with, and I've taken some inspiration on how they approach scaling and maintaining test suites to showcase how it can be applied in unit tests.
Think this would help others?