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.
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.
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.
Let's initially draw some core parallels between what we just covered and the initial react-testing-library example.
React testing library | E2E (End to end) | |
---|---|---|
rendering | render(Element) function | go to url (http://mywebsite.com) |
getting elements | getBy, queryBy... | $, $$, xPath (framework dependent) |
cases | code driven | user driven and NFR driven |
connectivity | mocks, spies | stubs, 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.
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:
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:
Some other notes:
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? |
|