Skip to content

Writing tests

Ainur Gimadeev edited this page Dec 5, 2022 · 23 revisions

Table of contents

  1. General testing process
  2. Essential classes
    1. StageTest
    2. ReactTest
    3. Page
    4. CheckResult
    5. WrongAnswer, TestPassed
  3. Testing contexts
    1. Browser context
    2. Node.js context (environment)
      1. Combining contexts
      2. Accessing browser context from the Node.js
      3. Interaction with HTML elements
      4. Getting HTML elements properties
      5. Event handling
      6. Multipage testing

General testing process

You can see the abstract testing algorithm in the code block below.

for (test in tests) {
    result = invoke(test)
    if (not result.correct) {
        break
    }
}

As you can see, there are some methods that are invoked and return a result. The first failed test stops the whole testing. Let's see how to implement these methods.

Essential classes

All of the classes and methods we will discuss below can be imported from the hs-test-web module:

const {StageTest, ReactTest, correct, wrong} = require("hs-test-web")

StageTest

Every hs-test-web test should one way or another extend StageTest class (or extend another class that extends this one).

Example:

class VirtualPianoTest extends StageTest { }

StageTest class has tests field that represent an array of test cases. Each test case is a function that will be executed in the browser or Node.js context. You will know more about contexts in the following sections.

class Test extends StageTest {

    tests = [
        // test cases should be declared here
    ]
}

ReactTest

This class is used to test React projects. The test class for the React project should extend ReactTest class. The features of testing React projects are described in the section React Project

Example:

class MineSweeperTest extends ReactTest { }

Page

StageTest class has method getPage(url) that accepts an website URL or path to an HTML file and returns an instance of a Page class. Each instance of the Page class represents the browser tab, where the test cases can be executed.

All the Page class instances should be declared as fields of the class that extends StageTest class:

class Test extends StageTest {
    
    page = this.getPage(url)

}

Page class instances will be used to run test cases in the browser context or you will need them to interact with HTML elements in Node.js context. You will learn more about contexts in the following sections.

Page class objects have the following fields:

  • tests - array of test cases.
  • requests - array of catched request from the page. Each element of array is puppeteer.HTTPRequest object.

Page clas objects have the following methods to work with browser tabs:

  • async navigate(url) - navigates to the url.
  • async refresh() - refreshes the page and waits until domcontentloaded event.
  • url() - returns current URL of the page.
  • async waitForEvent(eventName, timeout) - returns Promise that resolves into a boolean value, that describes if the event is emitted on the page or not. Learn more about the method in the Event handling section.

The browser tab is opened when one of the following methods is called for the first time:

  • execute(func) - is used to declare test case in tests field
  • evaluate(func) - is used to execute func function on the page from the Node.js context
  • find methods - are used to find HTML elements from the Node.js context. You will learn about these methods in the Node.js context section

CheckResult

CheckResult object represents the result of the test. In case of a failed test, a special message will be shown to the user that should be specified in tests. This message is called feedback. It should be understood that the correct() method is a short form of CheckResult.correct(). The same for wrong() method. Both methods can be imported from the hs-test-web module const {correct, wrong} = require("hs-test-web")

If a user's program failed the test feedback should be shown to the user to help to resolve their problem or to find a mistake. Please, do not write feedbacks like Wrong answer or Try again - such feedbacks are useless for the user. For example, instead of Your answer is wrong you can help the user by providing clearer feedback: Cannot find element with class 'container'. See the difference: in this case, it becomes obvious for the user that they may be somehow missed class name of an HTML element. Remember that Hyperskill is a learning platform and feedbacks of tests must help the user to spot the mistake.

Example:

const {StageTest, correct, wrong} = require("hs-test-web")
const pagePath = 'file://' + path.resolve(__dirname, './index.html');

class Test extends StageTest {

    page = this.getPage(pagePath)

    tests = [
        this.page.execute(() => {
            let containerElements = document.getElementsByClassName('container');     
            
            if (containerElements.length === 0) {
                return wrong(`Cannot find element with class 'container'`);
            } else if (containerElements.length > 1) {
                return wrong(`Found ${containerElements.length} elements with class 'container'` +
                    `, the page should contain just a single such element.`);
            }       

            return correct()
        })
     ]
}

WrongAnswer, TestPassed

⚠️ These classes are supported only in Node.js context

These exceptions are very useful if you want to finish testing being deep into several methods. It can be useful if you are under a lot of methods and don't want to continue checking everything else.

Throwing WrongAnswer exception will be treated like returning CheckResult.wrong(...) object. It can be used often. It is recommended to use this class everywhere instead of CheckResult.wrong(...)

Throwing TestPassed exception will be treated like returning CheckResult.correct() object. It is used rarely.

Examples:

throw new WrongAnswer("Count should be 42, found " + count);
throw new TestPassed();

Testing contexts

There are two types of the context where the test cases are executed.

Browser context

The test cases are executed inside of the browser on the chosen page. You can imagine that you are running code in the DevTools console. To run the test case on a page you should call its execute() method that accepts the arrow function. Inside the arrow function, you should write a code that will be executed on the page:

const {StageTest, correct, wrong} = require('hs-test-web');

class Test extends StageTest {

    mainPage = this.getPage(URL)

    tests = [
        this.mainPage.execute(() => {
            // this code will be executed on the 'mainPage' page.
            const element = document.getElementById('wrapper')
            return correct()
        })
    ]
}

We recommend using the browser context to check elements for their existence, styles, and attributes. Because when interacting with HTML elements, e.g. when you click on them, the page may reload or redirect to another page. In this case, page context will be destroyed and further code execution will be impossible, which will lead to errors. To avoid such challenges we recommend you to use Node.js context (environment) to interact with HTML elements.

Node.js context (environment)

In the Node.js context you can interact with a page object itself. You can reload the page, navigate between web pages, and so on. To execute test case in the Node.js environment you should use execute() method of the node field that comes with StageTest class:

class Test extends StageTest {

    tests = [
        this.node.execute(async () => {
            // this code will be executed in the Node.js context

            const inputField = await this.page.findById('input-text')
            await inputField.inputText('test text content!')
            const updateTextButton = await this.page.findById('update-text')

            await updateTextButton.click()

            const textDiv = await this.page.findById('text-from-input')
            if (await textDiv.textContent() !== 'test text content!') {
                return wrong('wrong text content!')
            }

            return correct()
        })
    ]
}

Tests executed in the context of the browser and Node.js can be combined

class Test extends StageTest {

    page = this.getPage(pagePath)

    tests = [
        this.node.execute(() => {
            return correct()
        }),
        this.page.execute(() => {
            return correct()
        })
    ]
}

Accessing browser context from the Node.js

Sometimes you might need to get some data from the browser context and process it in the Node.js using special modules. It can be done by calling evaluate() execute method of the needed Page object:

class Test extends StageTest {

    page = this.getPage(pagePath)

    tests = [
        this.page.execute(() => {
            this.getPixels = () => {
                const canvas = document.getElementsByTagName("canvas")[0];
                if (canvas.width !== 30 || canvas.height !== 30) {
                    return wrong("After uploading an image into canvas it has wrong size!")
                }
                const ctx = canvas.getContext("2d");
                return ctx.getImageData(0, 0, canvas.width, canvas.height).data;
            }
            return correct()
        }),
        this.node.execute(() => {
            let userPixels = await page.evaluate(() => {
                return this.getPixels()
            });

            let realPixels = await pixels(brightnessTestImage)

            let compareResult = comparePixels(userPixels, realPixels.data);
            if (!compareResult) {
                return wrong("After increasing brightness of the image it has wrong pixel values!")
            }

            return correct()
        })
    ]
}

It is possible that the required data may not exist in the context of the browser. In this case you can return wrong(message) and the test in Node.js will fail with feedback message:

this.node.execute(async () => {

    await this.page.evaluate(async () => {
        return wrong('wrong result!') // will fail the test
    })

    return correct()
})

Also you can pass the test by returning correct() from the browser context:

this.node.execute(async () => {

    await this.page.evaluate(async () => {
        return correct() // will pass the test
    })

    return wrong('wrong result!')
})

From the puppeteer documentation you can learn how to pass arguments to the evaluate method.

Interaction with HTML elements

Finding elements on the page

There are several asynchronous functions that will help you to find HTML elements on the page:

  • async findById(id) - finds element by its ID. If such an element doesn't exist then null will be returned
  • async findByClassName(className) - finds the first element with className class. If such an element doesn't exist then null will be returned
  • async findBySelector(selector) - finds the first element using selector selector. If such an element doesn't exist then null will be returned
  • async findAllByClassName(className) - finds all elements with className class. If any element wasn't found then an empty array is returned []
  • async findAllBySelector(selector) - finds all elements using selector selector. If any element wasn't found then an empty array is returned []

You should await executions for every function using await keyword.

Each HTML element in the Node.js context represents an object of the Element class that has all functions described above, so you can HTML elements inside of an HTML element. When you call find*() methods from Page class object, the search happens inside the <body> tag.

class TestFindElements extends StageTest {

    page = this.getPage(pagePath)

    tests = [
        this.node.execute(async () => {
            const test1 = await this.page.findAllByClassName('t1')
            const test2 = await this.page.findAllBySelector('div.t1')
            const test3 = await this.page.findByClassName('test')
            const another = await test3.findByClassName('another')
            const t2s1 = await another.findAllByClassName('t2')
            if (t2s1.length !== 3) {
                return wrong("wrong!")
            }
            const t2s2 = await another.findAllBySelector('div.t2')
            if (t2s2.length !== 3) {
                return wrong("wrong!")
            }
            return correct()
        })
    ]
}

Getting HTML elements properties

Element class has several methods to get an HTML element attributes:

  • async textContent() - returns text content of an HTML element
  • async innerHtml() - returns inner HTML of an HTML element
  • async className() - returns the class attribute of an HTML element
  • async getProperty(property) - returns an property of an HTML element by its property name
  • async getAttribute(attribute) - returns an attribute of an HTML element by its attribute name
  • async getStyle() - returns object with styles that applied to and HTML element
  • async getComputedStyle() - returns object of the computed styles of an HTML element

The values of specific styles can be accessed from the object fields:

const containerStyles = await container.getStyles()

if (containerStyles.backgroundColor !== 'red') {
    return wrong('wrong backgroundColor')
}

const containerComputedStyles = await container.getComputedStyles()

if (containerComputedStyles.backgroundColor !== 'rgb(255, 0, 0)') {
    return wrong('wrong backgroundColor')
}

Interaction with HTML elements

The following methods allow you to interact with HTML elements:

  • async click() - simulates a mouse click on an element
  • async type(text) - focuses on the element and simulates the input of text from the keyboard
  • async focus() - focuses on the element
  • async hover() - hover the element
  • async waitForEvent(eventName, timeout) - returns Promise that resolves into a boolean value, that describes if the event is emitted on the element or not. Learn more about the method in the Event handling section.

Event handling

To handle JS events you can use waitForEvent(eventName, timeout) function, where eventName - is the name of the event to wait for, and timeout is a timeout of waiting for the event.

The waitForEvent function returns a Promise that resolves into boolean value: true - if the event is emitted within timeoutms, otherwise false.

Keep in mind that you mustn't await for the waitForEvent function, otherwise, it will stop executing code and return false after timeoutms. Instead, save the pending Promise into a variable, interact with the elements that are expected to emit events, only then await for Promise to resolve. According to the Promise resolved value make a conclusion if the expected event had been emitted or not. Example:

class Test extends StageTest {

    page = this.getPage(pagePath)

    tests = [
        this.node.execute(async () => {
            const button = await this.page.findById("test-button");
            const isEventHappened = button.waitForEvent("click", 2000);
            await button.click();
            return (await isEventHappened === true) ? correct() : wrong("Expected click event on button with 'test-button' id!");
        })
    ]
}

If the waitForEvent function is called from the Element object then the event is handled for that element. If the function is called from the Page object then the event is handled for the window object.

Multipage testing

The test library has the ability to open multiple pages during tests. To do this, create several Page objects and call the execute() method for each page:

class Test extends StageTest {

    page1 = this.getPage(page1Url)
    page2 = this.getPage(page2Url)

    tests = [
        this.page1.execute(() => {
            return correct()
        }),
        this.page2.execute(() => {
            return correct()
        })
    ]
}

It's important to understand that the page opens only when the execute() method is invoked on that page for the first time. From the example above, page1 is opened, then the test case is executed in the context of that page, then page2 is opened, then the test case is executed in the context of that page, then all pages are closed and the tests are terminated.