English
BLOG

Readability of Tests, DSL and Refactoring

 

When programming, properly naming the elements of our code is essential for readability, maintainability, and for other developers to understand our work. This is particularly true for frontend tests, which can be complex due to user interactions and dynamic changes in the user interface. Clear and precise test names can help easily understand the purpose of each test, document the tested functionalities, and organize tests into logical groups. What about their content?

 

Let's take as an example a login page in React that informs the user when one of the fields is empty.

import type { FormEvent } from 'react';
import React, { useState } from 'react';


interface Props {
 onLogin: (email: string, password: string) => void;
}

export function Login({ onLogin }: Props) {
 const [error, setError] = useState('');

 const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
   const {
     email: { value: email },
     password: { value: password }
   } = event.target.elements;

   if (!email) {
     setError('Email is required');
   } else if (!password) {
     setError('Password is required');
   } else {
     setError('');
     onLogin(email, password);
   }
 };

 return (
   <form onSubmit={handleSubmit}>
     <label htmlFor="email">Email</label>
     <input id="email" name="email" type="text" />


     <label htmlFor="password">Password</label>
     <input id="password" name="password" type="password" />

     <button type="submit">Submit</button>

     {error ? <div role="alert">{error}</div> : null}
   </form>
 );
}

Here is a series of tests that validate that our implementation is indeed functioning correctly.

describe('Login', () => {
 it('should call onLogin when clicking on the submit button given email and password are filled', () => {
   const onLogin = jest.fn();
   render(<Login onLogin={onLogin} />);

   const emailInput = screen.getByLabelText(/Email/);
   userEvent.type(emailInput, 'email@email.ca');
   const passwordInput = screen.getByLabelText(/Password/);
   userEvent.type(passwordInput, 'password-123');

   const submitButton = screen.getByRole('button', { name: /Submit/ });
   userEvent.click(submitButton);

   expect(onLogin).toHaveBeenCalledWith('email@email.ca', 'password-123');
 });

 it('should display email is required when submitting the form without email', () => {
   render(<Login onLogin={jest.fn()} />);

   const passwordInput = screen.getByLabelText(/Password/);
   userEvent.type(passwordInput, 'password-123');

   const submitButton = screen.getByRole('button', { name: /Submit/ });
   userEvent.click(submitButton);

   const emailRequiredError = screen.getByText(/Email is required/);
   expect(emailRequiredError).toBeInTheDocument();
 });

 it('should display password is required when submitting the form without password', () => {
   render(<Login onLogin={jest.fn()} />);

   const emailInput = screen.getByLabelText(/Email/);
   userEvent.type(emailInput, 'email@email.ca');

   const submitButton = screen.getByRole('button', { name: /Submit/ });
   userEvent.click(submitButton);

   const emailRequiredError = screen.getByText(/Password is required/);
   expect(emailRequiredError).toBeInTheDocument();
 });
});

At first glance, the tests appear to be more complex than they should be for the simplicity of the behavior they validate. In fact, there are many elements specific to the libraries used (Jest, Testing-Library, User-Event, and React). However, the goal of the tests is to validate the user's behavior on the page, not its implementation. Users of the application are not aware of the technologies used. UI tests should be read in the same way as someone observing the user on the platform.

Domain Specific Language

In order to write our tests in a way that resembles how the user will interact with our application, we can create a Domain Specific Language (DSL). The DSL is a language designed to address specific problems in a particular domain. In our case, the domain is the various elements and actions that make up the login page. DSLs are generally simpler to use and learn than general-purpose programming languages because they are designed to reflect the vocabulary of the domain.

 

The first step in defining our DSL is to ask ourselves the following questions:

  • What are the different elements that the user sees on the application?
  • What actions can the user perform on the page?
  • What can the user expect when performing action X?

The answers to these questions will form the basis of our DSL. Here is an example of how our login page DSL could look like:

const loadLoginPage = () => {
 const onLogin = jest.fn();
 render(<Login onLogin={onLogin} />);

 return onLogin;
};

const fillPassword = (password: string) => {
 const passwordInput = screen.getByLabelText(/Password/);
 userEvent.type(passwordInput, password);
};

const fillEmail = (email: string) => {
 const emailInput = screen.getByLabelText(/Email/);
 userEvent.type(emailInput, email);
};

const clickOnSubmit = () => {
 const submitButton = screen.getByRole('button', { name: /Submit/ });
 userEvent.click(submitButton);
};

const shouldDisplayError = (error: RegExp) => {
 const emailRequiredError = screen.getByText(error);
 expect(emailRequiredError).toBeInTheDocument();
};

The user can:

  • See the login page
  • Perform 3 actions (enter the email, enter the password, and click submit)
  • Expect to see an error message

Using our new DSL in our test suite, we end up with:

describe('Login', () => {
 it('should call onLogin when clicking on the submit button given email and password are filled', () => {
   const onLogin = loadLoginPage();

   fillEmail('email@email.ca');
   fillPassword('password-123');
   clickOnSubmit();

   expect(onLogin).toHaveBeenCalledWith('email@email.ca', 'password-123');
 });

 it('should display email is required when submitting the form without email', () => {
   loadLoginPage();

   fillPassword('password-123');
   clickOnSubmit();

   shouldDisplayError(/Email is required/);
 });

 it('should display password is required when submitting the form without password', () => {
   loadLoginPage();

   fillEmail('email@email.ca');
   clickOnSubmit();

   shouldDisplayError(/Password is required/);
 });

As we can see, the flow of the tests is now much clearer to follow! The entire test name is clearly represented in the test.

Furthermore, this refactoring has eliminated code duplication, specifically the different possible actions on the interface.

Conclusion

Automated tests are an excellent source of documentation for our software. It is important that they are easy to understand at first glance. To achieve this, creating a Domain Specific Language (DSL) allows us to simplify the different steps in a language that is understandable to everyone. This DSL also helps us build the next feature validations.

Les articles en vedette
Testing React: Avoid Nesting
Testing Your Front-End Application
How to Justify a Refactor?
PARTAGER

Soyez les premiers au courant des derniers articles publiés

Abonnez-vous à l’infolettre pour ne jamais rater une nouvelle publication de notre blogue et toutes nos nouvelles.