Skip to content

Reusing and Composing Tests

As your test suite grows, it’s common to repeat the same flows across many tests — logging in, setting up test data, navigating to a specific page, or performing shared cleanup steps.

UI-licious encourages you to reuse and compose tests instead of duplicating these steps. This keeps tests shorter, easier to maintain, and less error-prone when flows change.

Composing tests with TEST.run

UI-licious provides TEST.run() to execute another test file from within the current test.

js
TEST.run("flows/login")

This allows you to:

  • extract shared flows into dedicated files
  • compose tests from smaller, focused scripts
  • avoid duplicating setup logic across tests

A common use case is reusing authentication flows.

Instead of repeating login steps in every test:

js
I.fill("Email", "[email protected]")
I.fill("Password", "secret")
I.click("Log In")

Extract them into a shared flow:

js
// file: flows/login.js
I.fill("Email", "[email protected]")
I.fill("Password", "secret")
I.click("Log In")

Then, reuse it:

js
TEST.run("flows/login")
I.see("Dashboard")

Important: Shared JavaScript Context

All test files executed via TEST.run() share the same JavaScript context and scope.

This means that:

  • variables declared in one test file are accessible in subsequently run test files
  • changes to variable values propagate to subsequent test files

Because of this, re-declaring the same variable name using let or const across test files may result in runtime errors.

If you need isolation between test files, wrap the test logic in a self-invoking function:

js
(function () {
  var data = TEST.loadDataFromJson("data/admin-user.json")
  I.fill("Username", data.username)
  I.fill("Password", data.password)
  I.click("Log In")
})()

This confines variables to the function scope TEST.run().

Shared setup and utility functions

In addition to shared flows, you may also want to reuse setup logic or helper functions across tests.

For setup or utility code that should only run once per test run, use TEST.runOnce() instead of TEST.run(). Subsequent calls to TEST.runOnce() with the same file path are ignored.

For example, a utility file for date formatting:

js
// file: utils/date_formatter
function formatDate_ddmmyyyy(date) {
  if (!(date instanceof Date) || isNaN(date)) {
    throw new Error("Invalid Date object")
  }

  const day = String(date.getDate()).padStart(2, "0")
  const month = String(date.getMonth() + 1).padStart(2, "0") // months are 0-based
  const year = date.getFullYear()

  return `${day}/${month}/${year}`
}

You can load the utility file and then use the functions in a test:

js
// load utilities
TEST.runOnce("utils/date_formatter")

// begin test case
var now = new Date()
I.fill("Check-in date", formatDate_ddmmyyyy(now))

Page Object Models

For larger or more complex flows, you can structure reusable logic around a feature using Page Object Models, implemented as plain JavaScript objects.

For example, a page object for a login form:

js
// file: models/login_form
var LOGIN_FORM = {
	fill(username, password){
		I.fill("Username", username)
		I.fill("Password", password)
	},
	clear(){
		I.clear("Username")
		I.clear("Password")
	},
	submit(){
		I.click("Log In")
	},
	hasError(message){
		// check error css is applied
		I.see("#login-form.has-error")
		// validate message is shown
		if(message){
			I.see(message)
		}
	},
}

Load the page object using TEST.run or TEST.runOnce:

js
TEST.run("models/login_form")

Then use it directly in your test:

js
LOGIN_FORM.fill("john", "supersecretpassword")
LOGIN_FORM.submit()
LOGIN_FORM.hasError("Invalid username or password.")

Page Object Models help keep tests readable while avoiding duplication, without introducing additional frameworks or abstractions.