Automated Testing Workshop Hands-On

Welcome to the "Automated Testing Workshop Hands-On". This workshop is designed to guide you through the process of setting up and using several important testing tools. We will cover static testing, unit testing, integration testing, and end-to-end testing.
 
Let's get started!
 
 
 
 

 

Initial Setup

 
  1. Go to and fork the repo by clicking the Fork button.
💡
NOTE: Make sure to uncheck Copy the main branch only. NOTE 2: Verify after forking that you have all the branches we need for this workshop…
 
notion image
 
  1. Clone your repo with command:
    1. git clone <REPO_URL>

1. Static Analysis Testing

 
Let's break down the static testing process step by step, using ESLint, Prettier, TypeScript, lint-staged, and Husky to improve a piece of JavaScript code.
 
Consider the following JavaScript object:
var participant = {firstName:'John',lastName:"Doe",age:25, attending:true}
 
This code has several issues:
  1. The variable is declared with var, which is not recommended because it's function-scoped, not block-scoped. This can lead to unexpected bugs.
  1. The object properties lack spaces after the colons, which is against common style guides in JavaScript.
  1. The variable name participant doesn't convey any type information, which TypeScript could provide.
 
Let's see how our tools can help address these issues.

ESLint

notion image
 
ESLint is a tool that enforces coding standards using a set of customizable rules. It can help catch style issues and minor bugs, ensuring our code is clean and consistent.
 
Setting up ESLint and running it on the file would flag the var declaration and the missing spaces in our object properties.
 
  1. Install ESLint: npm install eslint --save-dev
  1. Initialize ESLint: npx eslint --init
    1. Follow the instructions below and answer accordingly.
       
      How would you like to use ESLint?
      > To check syntax and find problems
      What type of modules does your project use?
      > None of these
      Which framework does your project use?
      > None of these
      Which framework does your project use?
      > No
      Which framework does your project use?
      > ✔︎ Browser
      > ✔︎ Node
      Would you like to install them now?
      > Yes
      Which package manager do you want to use?
      > npm
       
  1. Run ESLint: npx eslint index.js
1:5 error 'participant' is assigned a value but never used no-unused-vars
✖ 1 problem (1 error, 0 warnings)
 
  1. Let’s talk about configuring rules.
    1.  
      In ESLint, rules control what errors or warnings to display. Each rule can have its own severity level (0 = off, 1 = warn, 2 = error) and, optionally, some rule-specific options.
      For example, the rule "semi": [2, "always"] enforces the use of semicolons, and will cause an error (severity 2) if a semicolon is missing.
      Here's how to add this rule to your ESLint configuration file (.eslintrc.config.mjs):
      { "rules": { semi: [2, "always"], quotes: [2, "double"], } }
      💡
      To apply the above rule, update eslint.config.mjs and add the rules as per above to array of config.
      export default [ {files: ["**/*.js"], languageOptions: {sourceType: "script"}}, {languageOptions: { globals: {...globals.browser, ...globals.node} }}, pluginJs.configs.recommended, // add it here below 👇 { "rules": { semi: [2, "always"], quotes: [2, "double"], } } ];
      Run the command again: npx eslint index.js
      1:5 error 'participant' is assigned a value but never used no-unused-vars 1:30 error Strings must use doublequote quotes 1:75 error Missing semicolon semi
      ✖ 3 problems (3 errors, 0 warnings) 1 error and 0 warnings potentially fixable with the --fix option.
  1. We can use ESLint to fix common issues when it can by passing --fix command.
    1. npx eslint index.js --fix
       
      from:
      var participant = {firstName:'John',lastName:"Doe",age:25, attending:true}
      to
      var participant = {firstName:"John",lastName:"Doe",age:25, attending:true};
       
      And look at that, it might just be trivial that it automatically added a semicolon (;) and made use of double quotes (”) but you useful this can be when you want to update a large codebase in making things consistent and more importantly, it’ll help you catch some more errors.
 

Expand to learn more on this with React as an example
ESLint comes with a large number of built-in rules, and you can also add rules provided by plugins. For instance, if your project is using React, you might use the eslint-plugin-react to enforce React-specific best practices.
Here's an example of adding React-specific rules:
{ "plugins": ["react"], "rules": { "react/jsx-uses-react": "error", "react/jsx-uses-vars": "error", } }
The rule "react/jsx-uses-react" prevents React from being incorrectly marked as unused, and "react/jsx-uses-vars" prevents variables used in JSX from being incorrectly marked as unused.
Remember that ESLint rules should be configured to match your coding style and the specific requirements of your project.
 

Prettier

notion image
Prettier is an opinionated code formatter that enforces a consistent style by parsing your code and reprinting it with its own rules.
After running Prettier, it would automatically fix the formatting issue in our object, such as the missing spaces after the colons.
 
  1. Install Prettier: npm install --save-dev prettier
  1. Run Prettier: npx prettier --write index.js
    1. from:
      var participant = {firstName:"John",lastName:"Doe",age:25, attending:true};
      to:
      var participant = { firstName: "John", lastName: "Doe", age: 25, attending: true, };

TypeScript (optional)

notion image
TypeScript is a typed superset of JavaScript that adds static types to the language.
💡
This is completely optional as TypeScript (aka TS) has it’s own learning curve. Working with types might hinder you when you spend time resolving types instead of focusing on working on the logic.
 
  1. First, let’s rename index.js to index.ts to change this file as a TS file which allows us to use of TypeScript features.
 
  1. Second, let's define the Participant interface that contains the properties of a participant object.
    1. interface Participant { firstName: string; lastName: string; age: number; attending: boolean; }
  1. Then, we declare our participant variable and specify its type as Participant.
    1. const participant: Participant = { firstName: 'John', lastName: 'Doe', age: 25, attending: true, };
Now, TypeScript will alert us if we try to assign a value to participant that doesn't conform to the Participant interface.
 
For example, when we add favoriteColor , it shows a squiggly red underline that denotes that this property might not be incorrectly added as this property is not in Participant interface we define above.
notion image
To resolve this, we’ll have to add the said property in our interface above.
interface Participant { firstName: string; lastName: string; favoriteColor: string; // <-- added age: number; attending: boolean; }
One also added benefit that TS allows us is it gives us, for example when we’re using IDE like VSCode, it gives you auto suggestions that are properly typed.
notion image
 
💡
NOTE: If you choose to install TypeScript, re-run the configuration with ESLint above with npx eslint --init again. If you encounter error installing, delete the node_modules directory and package-lock.json and eslint.config.mjs file.

Automate linting and formatting with lint-staged & husky

Now, let's automate the process with lint-staged and husky.
 
Lint-staged runs linters on staged files, and Husky can prevent bad commits or pushes by enabling Git hooks.
 
  1. Install Husky and lint-staged: npm install husky lint-staged --save-dev
  1. Run npx husky init
    1. This creates a .husky in our root directory
      notion image
  1. Now let’s update pre-commit and make use of lint-staged, so let’s replace npm test with npx lint-staged
    1. notion image
  1. Let’s configure lint-staged to do run ESLint and Prettier in our .js and/or .ts files by creating a .lintstagedrc.json file with contents:
    1. { "*.{js,ts}": ["eslint --fix", "prettier --write --ignore-unknown"] }
  1. Then we can commit and it’ll automatically run our linter and code formatter.
    1. notion image
To resolve the issue above, let’s just log the participant
console.log(participant);
 
With this setup, every time you commit your code, Husky will trigger the pre-commit hook, which runs lint-staged. Lint-staged then runs ESLint and Prettier on your staged JavaScript and/or TypeScript files, automatically fixing any issues. This is how you ensure everyone on your team is following proper style guide and makes use of best practices.
 

2. Unit Testing

Our next focus will be on the concept of unit testing. Unit testing is a method of software testing that verifies the correctness of individual, isolated parts of a program.
The primary objective of unit testing is to isolate each part of the program and show that these individual parts are correct in terms of their behavior.
 
In this workshop, we will be utilizing
notion image
 

First, let’s create a codespace…

notion image
 

Now, let’s check out code snippet below…

 
Let's consider the following JavaScript function:
// person.js function getFullName(participant) { if (!participant?.firstName && !participant?.lastName) { return "First name and last name is required!" } if (!participant.lastName) { return "Last name is required!" } if (!participant.firstName) { return "First name is required!" } return participant.firstName + " " + participant.lastName; } module.exports = { getFullName };
This function is designed to concatenate the first name and the last name of a workshop participant.
 
However, what if the participant object does not possess the firstName and/or lastName properties which are both required in this case?
 
Let's walk through the process of setting of writing a test that verifies that getFullName handles the missing properties well.
 
  1. Install Jest by running the following command in your terminal: npm install --save-dev jest
  1. Next, create a test file named person.test.js. This file will contain our unit tests.
  1. To run Jest and thus, your unit tests, use the command: npx jest
 
With Jest set up, we can now write a test case that passes an object without firstName and lastName properties.
 
This allows us to ensure that our getFullName function is capable of handling our requirement.
 
Let’s write our first unit test…
 
SHOW CODE
test('getFullName without first name and last name is handled properly', () => { const participant = {}; const expected = "First name and last name is required!"; const result = getFullName(participant); expect(result).toBe(expected); });
 
In this test, we create a participant object without the firstName and lastName properties.
We then define our expected output as First name and last name is required!, since our function currently does not handle missing properties.
We then call our function with the participant object and expect the result to be our expected output. If the function does not return as per our expected, the test will fail, indicating that our function does not handle missing properties as expected.
 
This is how you can use Jest to write comprehensive unit tests, ensuring that your functions behave as expected even in edge cases. This not only makes your code more robust, but also makes future changes and additions to the codebase easier and safer.
 
You can read more about Jest and how to get started on its official site.
 

 

💪 CHALLENGE

 
  1. Write a test that will verify that first name is required
  1. Our requirement has changed, we now need to also need to pass our middleName. Our updated person.js below:
    1. function getFullName(participant) { if (!participant?.firstName && !participant?.middleName || !participant?.lastName) { return "First name, middle name, and last name is required!" } if (!participant.lastName) { return "Last name is required!" } if (!participant.firstName) { return "First name is required!" } return participant.firstName + " " + participant.lastName; } module.exports = { getFullName };
  1. Write a test that will verify that if middleName is missing, it returns Middle name is required!
    1. NOTE: We have left something intentionally for you to figure out…

3. Integration Testing

Integration testing is a phase in software testing where individual software modules are combined and tested as a group.
We will use the DOM Testing Library and React Testing Library for this part.
notion image
 

First, let’s create a codespace…

notion image
 
Let's start with a plain JavaScript example:
function greeting(person) { return 'Hello, ' + person.name; } function introduce(person) { return greeting(person) + '. I am ' + person.age + ' years old.'; } module.exports = { introduce, greeting }
In the code above, two functions greeting and introduce are interdependent. If greeting fails, introduce will also fail. Here are potential issues:
  1. If person is null or undefined, the code will throw a TypeError.
  1. If person does not have a name or age property, the output will include 'undefined'.
  1. If person.name or person.age is an empty string, the output will be incorrect.
 
We can use the Jest to write tests that catch these issues. Once again, let’s add jest to our project:
 
npm install --save-dev jest
 
And we can write tests to handle different scenarios such as it how it handles missing person, missing properties and empty properties.
 
Create a integration.test.js
// integration.test.js test('introduce handles missing person', () => { const result = introduce(); expect(result).toBe('Hello, undefined. I am undefined years old.'); });
 

💪 CHALLENGE

  • Create a test that will make sure introduce handles missing properties. Person is {} in this case as input.
  • Create a similar test that handles empty string property values. Person here is { name: '', age: '' }
 
💡
No peeking 🫣
SHOW SOLUTION
test('introduce handles missing properties', () => { const person = {}; const result = introduce(person); expect(result).toBe('Hello, undefined. I am undefined years old.'); }); test('introduce handles empty string property values', () => { const person = {name: '', age: ''}; const result = introduce(person); expect(result).toBe('Hello, . I am years old.'); });
 
 
 

 

Now, let's consider a React example:

 
Let’s skip the setup part but if you’re curious how, expand this section.
  1. To add support for JSX, add babel presets and add .babelrc to your project
    1. npm install --save-dev @babel/preset-env @babel/preset-react
      Create .babelrc file in the root directory
      { "presets": [ "@babel/preset-env", ["@babel/preset-react", { "runtime": "automatic" }] ] }
  1. Add react testing library dependencies
    1. npm install --save-dev @testing-library/react @testing-library/jest-dom
  1. To support SVG and CSS files add jest-svg-transformer and identity-obj-proxy. Then add into moduleMapper inside package.json jest config.
    1. In your package json, add the following line below.
      "jest": { "moduleNameMapper": { "^.+\\.svg$": "jest-svg-transformer", "^.+\\.(css|less|scss)$": "identity-obj-proxy" } }
  1. To support web environment API, install jest-environment-jsdom add into jest config and add in our previous jest config property in package.json earlier.
    1. npm install --save-dev jest-environment-jsdom
      "jest": { "testEnvironment": "jsdom", "moduleNameMapper": { "^.+\\.svg$": "jest-svg-transformer", "^.+\\.(css|less|scss)$": "identity-obj-proxy" } }
  1. Additionally add @testing-library/jest-dom package and configure setupTests.js in our root directory.
    1. npm install --save-dev @testing-library/jest-dom
      // setupTests.js import "@testing-library/jest-dom";
      "jest": { "testEnvironment": "jsdom", "moduleNameMapper": { "^.+\\.svg$": "jest-svg-transformer", "^.+\\.(css|less|scss)$": "identity-obj-proxy" }, "setupFilesAfterEnv": [ "<rootDir>/setupTests.js" ] }
 
Let’s focus on our code, instead. 😉
// App.jsx import React from "react"; function Greeting({ person }) { return <p>Hello, {person.name}</p>; } function Introduction({ person }) { return ( <div> <Greeting person={person} /> <p>I am {person.age} years old.</p> </div> ); } const person = { name: "John", age: 25, }; export default function App() { return <Introduction person={person} />; }
This code has similar issues as the plain JavaScript example.
 
We can use the React Testing Library to write tests that catch these issues:
 
  1. First, install the React Testing Library by running the following command in your terminal: npm install --save-dev jest @testing-library/react . Since we’ve already set it up properly, we don’t need to do this.
 
Proceed to next step below
 
  1. Then, create a new file named App.test.js inside the src directory. This file will contain our integration tests.
 
Here are some tests you might write for the App component:
import { render, screen } from "@testing-library/react"; import App from './App' test('Introduction handles missing person', () => { // ARRANGE render(<App />); // ACT // ASSERT expect(screen.getByText('Hello, John')).toBeInTheDocument(); expect(screen.getByText('I am 25 years old.')).toBeInTheDocument(); });
In these tests, we use the render function from React Testing Library to render our App component, and the based from screen (which you can think whatever RTL sees as a whole) function to find elements by their display text. We then use the toBeInTheDocument function from @testing-library/jest-dom to ensure the elements are in the document.
 
These tests ensure that the App component and its child components (Introduction and Greeting) handle missing or empty properties correctly.
 

 

💪 CHALLENGE

  • Write a test that will verify that <Introduction /> component can handle null value.
    • person={null}
  • Write a test that will verify that <Introduction /> component can handle missing properties
    • person={}
  • Same above but handle empty properties.
    • const person = { name: '', age: '' }
  • Can you combine all of these in one test?

💡
Ooops, no peeking! 🫣
SHOW SOLUTION
test('Introduction handles null person', () => { const { getByText } = render(<Introduction person={null} />); expect(getByText('Hello, ')).toBeInTheDocument(); expect(getByText('I am years old.')).toBeInTheDocument(); }); test('Introduction handles missing properties', () => { const person = {}; const { getByText } = render(<Introduction person={person} />); expect(getByText('Hello, ')).toBeInTheDocument(); expect(getByText('I am years old.')).toBeInTheDocument(); }); test('Introduction handles empty properties', () => { const person = {name: '', age: ''}; const { getByText } = render(<Introduction person={person} />); expect(getByText('Hello, ')).toBeInTheDocument(); expect(getByText('I am years old.')).toBeInTheDocument(); });
 

If you’re curious about how about if we’re not using a Framework like React?

 
Here's how you can setup DOM Testing Library and write tests for it:
 
First, let's install the DOM Testing Library and its peer dependencies.
 
  1. Install Jest: Jest is a JavaScript testing framework that we will use to run our tests.
npm install --save-dev jest
  1. Install @testing-library/jest-dom: This library provides custom Jest matchers that you can use to extend Jest's default matchers.
npm install --save-dev @testing-library/jest-dom
  1. Install @testing-library/dom: This is the DOM Testing Library that provides utilities for testing DOM nodes.
npm install --save-dev @testing-library/dom
 
Now that you've installed the necessary libraries, let's write some tests.
 
Create a new file named script.test.js in your project directory and add the following code:
<!DOCTYPE html> <html> <body> <p id="greeting"></p> <p id="introduction"></p> <script> var person = { name: "John", age: 25, }; document.getElementById("greeting").innerHTML = "Hello, " + person.name; document.getElementById("introduction").innerHTML = "I am " + person.age + " years old."; </script> </body> </html>
 
To start setting up DOM testing library and writing tests for the provided code snippet, please follow the steps below:
  1. First, install the DOM testing library. Use npm or yarn to add it to your project:
npm install --save-dev @testing-library/dom # or yarn add --dev @testing-library/dom
  1. Once installed, you can now start writing tests. Create a new test file (e.g., app.test.js). In this file, you'll import the necessary functions from the DOM Testing Library, and write your tests.
import { getByText, getByTestId } from '@testing-library/dom' import '@testing-library/jest-dom/extend-expect' test('displays greeting and introduction correctly', () => { // Create a new HTML document and set its body to the provided HTML snippet document.body.innerHTML = ` <html> <body> <p id="greeting"></p> <p id="introduction"></p> </body> </html> `; // Simulate the script from the provided HTML snippet var person = { name: "John", age: 25, }; document.getElementById("greeting").innerHTML = "Hello, " + person.name; document.getElementById("introduction").innerHTML = "I am " + person.age + " years old."; // Now, we can start our assertions expect(getByText(document.body, 'Hello, John')).toBeInTheDocument(); expect(getByText(document.body, 'I am 25 years old.')).toBeInTheDocument(); });
In the test, we first create a new HTML document and set its body to the provided HTML snippet. We then simulate the script that runs on this HTML, which sets the innerHTML of the elements with IDs of "greeting" and "introduction". Finally, we use the getByText function from the DOM Testing Library to find the elements by their text content, and assert that they're in the document.
Finally, you can run your tests using Jest:
npx jest
This will run your tests and provide output in your terminal.
 
 
 

4. End to End Testing with a Sample React Application

Finally, we will cover end-to-end testing with a practical example by using a sample React application.
 
In your cloned repo, in root directory, run command:
 
git checkout 03-end-to-end-testing
 
💡
NOTE: Since it’s going to take a while to install things out, let’s first start with initializing Playwright and explain things later. Run command in your terminal: npm init playwright@latest Getting started with writing end-to-end tests with Playwright: Initializing project in '.' ✔ Do you want to use TypeScript or JavaScript? · JavaScript ✔ Where to put your end-to-end tests? · tests ✔ Add a GitHub Actions workflow? (y/N) · false ✔ Install Playwright browsers (can be done manually via 'npx playwright install')? (Y/n) · true ✔ Install Playwright operating system dependencies (requires sudo / root - can be done manually via 'sudo npx playwright install-deps')? (y/N) · true
 
 
 

Now let’s focus our attention to our code while waiting…

 
Our example React app has two basic functionalities:
 
  1. Login with credentials - user & pass
  1. Post a message one time. If you post again, it’ll replace the previous message.
 
import React, { useState } from 'react'; function App() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [isLoggedIn, setIsLoggedIn] = useState(false); const [message, setMessage] = useState(''); const [post, setPost] = useState(''); const handleLogin = () => { if(username === 'user' && password === 'pass') { setIsLoggedIn(true); } } const handlePost = () => { setPost(`${username}: ${message}`); setMessage(''); } return ( <div> {!isLoggedIn ? ( <div> <input type="text" onChange={(e) => setUsername(e.target.value)} placeholder="Username" /> <input type="password" onChange={(e) => setPassword(e.target.value)} placeholder="Password" /> <button onClick={handleLogin}>Login</button> </div> ) : ( <div> <input type="text" value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Write a message" /> <button onClick={handlePost}>Post</button> <p>{post}</p> </div> )} </div> ); } export default App;
 
With this setup, our application allows a user to "login" with static credentials (username: 'user', password: 'pass'), write a message and post it.
End-to-end testing is testing the real application runtime like how normally users interact with the app, meaning, clicking a button, filling out an some text input fields, etc.
 

Let’s run our application

npm run dev
And open http://localhost:5173 in our browser.
notion image
 
Play around it to get familiar with the functionality and verify they work according to what we described earlier.
 
You’ve just done manual testing! Naks. 😉
 
Now, let's write end-to-end tests using Playwright.
 
notion image
Playwright is a Node.js library to automate Chromium, Firefox and WebKit browsers with a single API.
 
It is a perfect tool to automate these kinds of processes.
 
What we particularly like about Playwright is it has a code generator that allows us to record our steps.
 
Run command in a new terminal:
npx playwright codegen http://localhost:5173/
notion image
 
After performing the same steps we did, we now this code snippet below we can use to create a test file.
 
Now, let’s create a app.spec.js in tests folder and paste the code we generated earlier.
Example below:
import { test, expect } from '@playwright/test'; test('test', async ({ page }) => { await page.goto('http://localhost:5173/'); await page.getByPlaceholder('Username').click(); await page.getByPlaceholder('Username').fill('user'); await page.getByPlaceholder('Password').click(); await page.getByPlaceholder('Password').fill('pass'); await page.getByRole('button', { name: 'Login' }).click(); await page.getByPlaceholder('Write a message').click(); await page.getByPlaceholder('Write a message').fill('ok rta?'); await page.getByRole('button', { name: 'Post' }).click(); await expect(page.getByText('user: ok rta?')).toBeVisible(); await page.getByPlaceholder('Write a message').click(); await page.getByPlaceholder('Write a message').fill('oo gd'); await page.getByRole('button', { name: 'Post' }).click(); await expect(page.getByRole('paragraph')).toContainText('user: oo gd'); });
 
To run our test, we can use the following commands:
 
npx playwright test - runs our tests headless (without the browser showing up)
 
 
npx playwright test --ui - starts the interactive UI mode.
notion image
 
Finally, you can run npx playwright show-report after each tests which will show you a summary of the test you just performed.
notion image
 
This is how you can use Playwright to write end-to-end tests, ensuring that your application behaves as expected. This not only makes your code more robust, but also makes future changes and additions to the codebase easier and safer.
 
To learn more, we suggest you do further reading at Playwright especially the Best Practices guide. Also, understand the concepts about Locators and why they’re preferred.
 

💪 CHALLENGE

  • Write tests for https://todomvc.com/examples/react/dist/ to verify the following:
    • It creates a todo
    • Todo can be completed
    • Todo can be removed
    • Todo can be filtered
    • Todo’s that are completed can be removed
    • Todos can be marked all as completed