Two computers with people writing on paper in between them

Unit Testing JavaScript/TypeScript in AEM with Jest

Hiten Patel, August 27, 2021

When there are tight timelines to meet, writing JS tests seems like unnecessary overhead. However, its benefits - identifying bugs earlier in the Software Development Life Cycle (SDLC), improving code quality and ensuring that code will fulfill its intended purpose if updated later - far outweigh the time invested implementing them.

Below I will outline the setup we did for one of our projects at 3|SHARE to implement JS unit testing using Jest and Babel.

Prerequisites

For the setup below, you'll need a project directory housing the AEM Archetype with the ui.frontend module available.

I recommend you use NodeJS v14.16.0+, and, ensure the frontend-maven-plugin reflects this in the root pom.xml so the correct version is picked up by the maven build. 

...
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>1.11.3</version>
<configuration>
   <nodeVersion>v14.16.0</nodeVersion>
   <npmVersion>6.14.11</npmVersion>
</configuration>
...

Setup

We opted to use Jest because of its popularity and ability to integrate with many popular libraries. 
First,  make sure you have included Jest (and if using TS, the appropriate types are installed so TS can understand Jest syntax) as part of the ui.frontend module. 
As Jest supports using TS through Babel, add the appropriate Babel presets (@babel/preset-env / @babel/preset-typescript ):

npm install jest --save-dev 
npm install @types/jest --save-dev
npm install @babel/preset-env --save-dev
npm install @babel/preset-typescript --save-dev

Within the .babelrc config, add the correct babel preset so it can transpile from TS:

{
   "presets": [
       "@babel/preset-typescript",
       "@babel/preset-env"
   ],
   "plugins": [
       ...
   ]
}

Next, identify a location for the test files. In the ui.frontend module, create a folder named test.
Inside the test directory create setupTests.ts file for the general config to apply for our tests.

For our purpose, we wanted to reset modules loaded in via the require statement, so we got a fresh version of the required module in each test iteration rather than a previously cached version which can cause undesirable test outcomes. By entering the below code in setupTests.ts file, we could ensure the resetting of each module:

beforeEach(() => {
	jest.resetModules();
})

Now,  include the configuration for Jest, so it knows where to look for the setupTests.ts, clear mocks before each test is run and which files to include in coverage analysis. Newer versions of Jest default the environment to NodeJs so when testing against the DOM, you need to explicitly let Jest know of our intention to use jsdom

Within the ui.frontend directory, create a jest.config.js file and add the following to set this:

module.exports = {
  clearMocks: true,
  setupFilesAfterEnv: ['<rootDir>/test/setupTests.ts'],
  collectCoverageFrom: ['src/site/**/*.{ts,js,jsx,mjs}'],
  testEnvironment: 'jsdom'
};

There is no defined way of organising the tests though for simplicity and ease of reference, I followed the structure defined in the ui.frontend/src/site/ directory.

i.e. if the main script is stored in ui.frontend/src/site/components/structure/video-page/VideoPage.ts, the associated test for it would be stored in ui.frontend/test/components/structure/video-page/VideoPage.test.ts

After that is done, we needed a way to run the tests and trigger the running of tests when we are building (i.e. if any tests fail, we should break the maven build).

First, create a script to run the tests. In package.json add another script as part of the scripts object as the following:

"scripts": {
  ...
  "test": "jest --passWithNoTests",
  "test-coverage": "jest --coverage --passWithNoTests",
  "test-watch": "jest --watch"
}

Include the flag --passWithNoTests so if there is no time to write the tests, the maven build will continue as normal since this indicates that there are no tests expected.

Test-coverage will show how much our test cases cover our scripts. Time permitting, we should ideally aim for 80% coverage.

For development purposes, we also added a test-watch script too, which will continually watch modified test files and report the output in the console. 

NOTE:  jest --watch will only work if you are in a git repository. If you are planning on using these commands outside of git please use jest --watchAll instead.

Within the prod script we needed to trigger the tests as maven uses this script, and ensure on failure that the build will break. Modify the prod script by prepending: npm run test && as below: 

"scripts": {
  ...
  "prod": "npm run test && webpack -p --config webpack/webpack.prod.js && clientlib --verbose",
  ...
},

DOM testing

Most of the JavaScript / TypeScript we write as FE developers at 3|SHARE relies on the DOM. We need to ensure that we have fixtures for the specific component in place for the test to run against.

With one of our projects as we were using JavaScript modules with TypeScript, we could simply include it in with an import statement:

import {SELECTORS, fixture} from './video-page-fixture';

The fixture file that we imported from:

export const SELECTORS = {
  VIDEO_PAGE_COMPONENT: '[data-cmp-is="video-page"]',
  DESCRIPTION_AREA: '.cmp-video-page__video-detail__description__inner',
  SHOW_MORE: '.js-show-more',
  MORE_TEXT: 'cmp-video-page__video-detail__description__inner--show-more',
  REVEAL_TEXT: 'cmp-video-page__video-detail__description__inner--reveal-text',
  HIDE: 'hide'
};

export const fixture = `
  <div class="cmp-video-page" data-cmp-is="video-page">
    <div class="cmp-video-page__video-bg">
      ...
    </div>
  </div>
`;

Here, we are set the selectors required for the component so they could be imported / used in the test as well as the component HTML.

Within the test file we set the body HTML so we could run test against the expected markup. The fixture variable should match the component markup (hopefully we can simplify this process and load HTL's directly at some stage, but currently not possible). 

Add a beforeEach callback to the test file, and inside set the body HTML to be the result of the fixture. Lastly, require / run the script that you are testing against so that each of the test cases has everything setup to run. That way, you can just focus on assertions:

describe("Video Page", () => {
  beforeEach(() => {
     document.body.innerHTML = fixture;
     // ...
     require('../../../../src/site/components/structure/video-page/VideoPage');
  });
  // ...
});

Hopefully now, if everything is set up correctly, you can run your normal top-level build command:

mvn clean install -PautoInstallPackagePublish

And for local development watching files, within ui.frontend you can run:

npm run test-watch

Exercise: Try following the example below

To illustrate the above, we have created a running example of our test setup. I have created a simple script which will update the text of a button on the page when clicked. You can follow along and create the same in your sandbox.

  1. Under ui.frontend/src/main/webpack/site create a folder called sandbox and inside create a file called Sandbox.ts. Within this file paste the following script:
    const SELECTORS = {
      SANDBOX_COMPONENT: '[data-cmp-is="sandbox"]',
      SANDBOX_COMPONENT_CONTAINER: '.cmp-sandbox',
      BUTTON: '.btn-update',
    }
    
    class Sandbox {
      $el: HTMLElement;
      $button: HTMLButtonElement;
    
      constructor($el) {
         this.$el = $el;
         this.$button = this.$el.querySelector(SELECTORS.BUTTON);
      }
    
      private addListeners(): void {
         this.$button.addEventListener('click', ()=>{
            this.$button.innerHTML = 'updated text';
         })
      }
    
      init(): void {
         this.$el.removeAttribute('data-cmp-is');
         this.addListeners();
      }
    }
    
    const sandboxEls: NodeListOf<HTMLElement> = document.querySelectorAll(SELECTORS.SANDBOX_COMPONENT);
    
    if (sandboxEls.length) {
      sandboxEls.forEach(($el: HTMLElement) => {
         const sandbox = new Sandbox($el);
         sandbox.init();
      })
    }
    
    As you have probably figured out this script will do the brunt of the work, lookout for the elements’ presence on the page and add the listener to update the text of the button upon clicking.
  2. Next in the ui.frontend/test folder we created earlier, create a sandbox folder. Within this create a sandbox.test.ts file and paste the following:
    import {SELECTORS, fixture} from "./sandbox-fixture";
        
    describe("Sandbox", () => {
        afterEach(() => {
    		document.body.innerHTML = "";
    	});
      
    	it('ensures component is available in DOM', () =>{
        	document.body.innerHTML = fixture;
        	require('../../src/main/webpack/site/sandbox/Sandbox');
        	const $container: HTMLElement = document.querySelector(SELECTORS.SANDBOX_COMPONENT_CONTAINER);
        	expect($container).not.toBeNull();
    	});
      
    	it('gracefully fails when component not available in DOM', () =>{
        	require('../../src/main/webpack/site/sandbox/Sandbox');
        	const $container: HTMLElement = document.querySelector(SELECTORS.SANDBOX_COMPONENT_CONTAINER);
        	expect($container).toBeNull();
    	});
      
    	it("updates button text on click", () => {
        	document.body.innerHTML = fixture;
        	require('../../src/main/webpack/site/sandbox/Sandbox');
        	const $button: HTMLButtonElement = document.querySelector(SELECTORS.BUTTON);
          
        	expect($button.innerHTML).toEqual('default button text');
        	expect($button.innerHTML).not.toEqual('updated text');
          
        	$button.click();
          
        	expect($button.innerHTML).not.toEqual('default button text');
        	expect($button.innerHTML).toEqual('updated text');
       	});
    });
    
    These tests assert the following:
    • When the fixture is available element <div class="cmp-sandbox"> exists.
    • When the fixture is not available element <div class="cmp-sandbox"> does not exist
    • Before clicking the button, button text contains the default text and not the updated text
    • After clicking the button, button text contains the updated text and not the default text
  3. For the above tests to run successfully we are dependent on the fixture, so add a file named sandbox-fixture.ts in the same directory as step 2 above (ui.frontend/test/sandbox). Paste the following inside:
    export const SELECTORS = {
    	SANDBOX_COMPONENT: '[data-cmp-is="sandbox"]',
    	SANDBOX_COMPONENT_CONTAINER: '.cmp-sandbox',
        BUTTON: '.btn-update',
    }
    
    export const fixture = `
    	<div class="cmp-sandbox" data-cmp-is="sandbox">
      		<button class="btn-update">default button text</button>
    	</div>
    `;
    
    Above just exports the selectors and fixture to be used in the test, so we know what is expected in the DOM to run the script against.

If you have been following along and added the same run scripts in the package.json as previously mentioned - inside a terminal navigate to the ui.frontend directory and run:

npm run test

You should see the below output if everything went well:

A screencap of a unit test passing

You can also see the coverage by running:

npm run test-coverage

A screencap of a table within the console

This is an ideal scenario and almost will be impossible to reach 100% coverage. We always try to maximise as much as possible when working on projects to give ourselves a safety net.

As we have modified the script (npm run prod) that runs on the maven build, at the top level of the project we can also run:

mvn clean install 

This should run our tests alongside the full AEM build and if for any reason our tests were to fail, it will rightly break the build and call for attention.

NOTE: you may come across a linting error with the above code, and that is due to the assignment of this to variables in the class constructor of Sandbox. If you do come across this update rules the eslintrc.js in ui.frontend rules to allow assigning for variables with the following:

rules:  {
   // ...
   "@typescript-eslint/no-this-alias": [0],
   // ...
},

Following the steps above should bring to light how you can leverage unit testing in AEM to help solidify and ensure robustness in your code, and in turn prevent any undesired side-effects / bugs in doing so.

You can download, review and reuse a working example of a project here, courtesy of 3|SHARE:
JS/TS Unit Testing in AEM by 3|SHARE

Questions? Contact 3|SHARE and we'll put you in touch with the right person. Just visit our Contact page and submit the form.

Photo by Scott Graham on Unsplash

Topics: Development, AEM, Tech, Testing

Hiten Patel

Hiten Patel is a Senior Frontend Developer at 3|SHARE. He enjoys working with colleagues he describes as great, knowledgeable and fun. He appreciates the fact that everyone is willing to share the latest about developments in AEM to keep the team current. In his down time, Hiten loves keeping fit with running and resistance exercises and also winding-down by gaming with friends.