Don't want to bother setting up a browser automation framework to run
a few tests? Just drop browser-test.html into your project and start
writing.
If you find yourself writing javascript and you need access to browser apis that are not available on server-side JS engines, you're faced with a hard choice:
- Mock out the relevant API. This may be very challenging based on the complexity of the API. And even with a working mock it will be difficult to know if the mock is accurate enough to adequately test your code.
- Setup browser automation with Selenium / Playwrite / Cypress or other tools. Heavyweight, hard to configure, slow, these tools work, but are not fun or easy to use. They also often move you up the ladder from unit tests to integration tests.
Now you have a third option: browser-test.html, the simplest way to
get automated tests running in the browser.
Features:
- Entire test framework lives in one html file. Just drop it into your project and write tests.
- Tests are run in the browser, so you have access to:
- All browser APIs. Test against the DOM, IndexedDB, local storage, etc.
- Dev tools. Set breakpoints, run things in the console
- Familiar testing syntax. Simple, but expressive.
- Bring your own browser means cross-browser testing is easy.
- The test framework is ~140 lines of human-written, easy-to-read javascript so it's easy to audit before dropping into your project.
Using browser-test.html is as simple as Copy, Serve, Write, Refresh:
- Copy the whole browser-test.html file into your project
- Serve the browser-test.html file and your javascript library on localhost
- Write tests directly into the
browser-test.htmlfile - Refresh the browser page to see the results
The browser-test.html file is MIT licensed, and has the attribution included, so you can just copy it directly into your project. For example, the following structure works:
your-project/
readme.md
src/
index.js
test/
browser-test.html
You'll need to bring your own file server and browser, but for the example structure above, serving can be as simple as:
cd your-project
caddy fileserver --location :8080
firefox http://localhost:8080/test/browser-test.htmlWriting tests is as simple as adding directly to the <script> in
the browser-test.html file. You'll need to add code in two places:
- At the top of the
<script>import your code - Below the framework code, write your tests
<script type="module">
// import your code here
import { yourFunction } from '/src/index.js'
class Tester {
// The browser-test framework is defined here
}
// Your tests go here
describe("yourFunction", () => {
expectEqual("Returns 1337", 1337, async () => {
return await yourFunction();
})
})
// leave in the test.run() call, it runs your tests
test.run();
</script>In the browser-test.html file there are big block comments
instructing where to put tests, so you shouldn't get lost.
The file also includes a set of example tests that test the framework itself. You can start by running these to ensure everything is working, and use them as an example for writing your own tests.
Note: Your import path may change based on how you've structured your project and how you're serving the files.
The results of your tests show up in the browser window. Green is passing, Red is failing. To see new tests and results you just need to refresh the window.
Because tests are running in your browser window, you also have full access to debugging tools and the console during test runs. If tests aren't showing up, look in the development console for errors.
Note: Depending on how your browser caches dependencies you may need to do a hard refresh (e.g. Ctr-Shift-R on firefox) to pick up code changes.
The Testing API is similar to Jest, but vastly simplified.
- Use
describeto create sections of tests - Perform tests with the
expectfamily of functions. - Create fixtures with
fixto pull out common setup and teardown functionality.
Create a new testing context. A new testing context means:
- A new section in the results output
- A new closure for definitions and fixtures
Usage: describe(<name>, <body>)
At it's simplest, describe can be used to structure your tests
hierarchically.
describe("My Tests", () => {
describe("Function 1", () => {
expectEqual("Returns 100", 100, () => function1());
})
describe("Function 2", () => {
expectEqual("Returns 200", 200, () => function2());
})
});More advanced uses are also possible. Within the body callback you can:
- Run arbitrary code that you can close over with tests or further
describecalls. - Run
fixto attach fixtures to subsequent tests. - Run any number of the
expectfamily of functions to perform tests. - Create arbitrarily nested contexts with more
describecalls. - Use
awaitas long as the callback is marked asasync.
An example that shows how these features could be used:
describe('myClass', () => {
const id = 123;
const instance = myClass(id);
expectEqual('id is set', id, () => {
return myClass.id;
})
describe('myClass.myMethod', async () => {
fix(
'setup',
async () => await instance.runSetup(),
async () => await instance.runTeardown(),
);
expectEqual("Fetches correct default resource", 777, async ({ setup }) => {
const resourceId = setup.id;
const resource = await instance.myMethod(resourceId)
return resource.value;
});
expectErr('invalid resource is an error', async () => {
await instance.myMethod("INVALID")
});
})
})These functions represent the testing unit for the framework. There are four different functions, useful for different scenarios:
expect: Checks a result istrue.expectEqual: Checks the result equals a value.expectDeepEqual: Checks a result deep into objects and arraysexpectErr: Checks that an error is thrown
For all of the expect family of functions the first argument should
be the name of the test and the last argument is an (optionally async)
callback which runs your test code.
By default the callback you provide takes no arguments, and must
return the value to be compared against. In all of these functions
besides expectErr any unhandled error is treated as a test failure.
A test that passes when the provided callback returns true.
Usage: expect(<name>, <callback>)
Examples:
Simple tests:
# Passes
expect("true", () => true);
# Fails:
expect("false", () => false);
# Fails:
expect("doesn't error", async () => {
await Promise.reject("always throws");
return true;
});Sequences of tests. Since any error fails a test, expect can be used
with assert
expect("Everything works", async () => {
const data = await setup();
assert(data !=== undefined, "Setup ran");
const output = await process(data);
assert(output === 4, "Output should be 4");
const didCleanup = await cleanup(output);
assert(didCleanup, "cleanup succeeded");
return true;
})A test that passes when the provided callback returns a value equal to the value provided.
Usage: expect(<name>, <expected value>, <callback>)
Examples:
# Passes
expectEqual("1 + 1 = 2", 2, () => 1 + 1);
# Fails
expectEqual("2 + 2 = 5", 5, () => 2 + 2);
# Fails -- Must use expectDeepEqual
expectEqual("same array values", [1, 2, 3], () => [1, 2, 3]);Note: Since object / array comparisons in javascript are done by
object identity, if you want to compare objects / arrays you must use
expectDeepEqual as described below
A test that passes when the provided callback returns a value
This is checked by encoding the provided value and the returned value as JSON and doing a string comparison on the output.
Usage: expectDeepEqual(<name>, <expected value>, <callback>)
Examples:
# Passes
expectDeepEqual("Arrays are equal", [1, 2, 3], () => [1, 2, 3])
# Passes
expectDeepEqual(
"deep object comparison",
{
"first": [1, 2, 3],
"second": {
"third": {
a: 1,
b: 2,
c: 3,
},
},
},
() => {
return {
"first": [1, 2, 3],
"second": {
"third": { a: 1, b: 2, c: 3, },
},
},
}
)
# Fails
expectDeepEqual("Order matters", {a: 1, b: 2}, () => {b: 2, a: 1});Note: Because the comparison is string comparison after JSON
encoding, object comparisons are (overly) sensitive to ordering. This
can be mitigated by either carefully arranging your field order in the
expected value or calling Object.entries(val).toSorted() on both
sides.
A test that passes when the provided callback encounters an unhandled error.
Usage: expectErr(<name>, <callback>)
Examples:
# Passes
expectErr("Throw an error", () => {
throw new Error("Oops!");
})
# Passes
expectErr("Awaiting a rejected promise", async () => {
await Promise.reject("Failed");
})
# Fails
expectErr("No error thrown", () => 3)Note: expectErr does not detect the value of the error, so test
functions can pass for different errors than you intended. Be sure to
look at the test output in the browser window to ensure the error
you're seeing it the error your test expects.
Register a fixture to be run for every test in this text context and descendant test contexts.
Before each tests is run, all fixtures in its context will be run. Each test will be passed as it's only argument an object mapping the name of each fixture to it's result. After each test is run, all teardown functions will be run, with the fixture results passed in.
Usage: fix(<name>, <setupFn>, [<teardownFn>])
Examples:
describe("Simple fixtures, no teardown required", () => {
fix("three", () => 3);
fix("asyncThree", async () => await 3)
expectEqual("three is 3", 3, ({three}) => three);
expectEqual("async three is 3", 3, ({asyncThree}) => three);
})
describe("Fixture with setup and teardown", () => {
const data = {};
fix(
"dataStoreKey",
() => {
const uuid = crypto.randomUUID();
data[uuid] = 12;
return uuid;
},
({dataStoreKey}) => {
delete data[dataStoreKey];
}
);
expectEqual("seeded data", 12, ({dataStoreKey}) => {
return data[dataStoreKey]
})
})
describe("Async fixtures", () => {
fix("asyncThree", async () => await 3);
fix("promiseThree", () => Promise.resolve(3));
fix("closureThree", () => (() => Promise.resolve(3)));
expectEqual("asyncThree", 3, ({asyncThree}) => asyncThree);
expectEqual("promiseThree", 3, ({promiseThree}) => promiseThree);
expectEqual(
"closureThree", 3, async ({closureThree}) => await closureThree()
);
})Notes:
-
All in-scope fixtures are run for every test, even if their results are unused.
-
Because tests are run asynchronously, you cannot rely on a teardown function running before another test's setup function has run. You must still assume that any shared data can be updated in any order. In general it is a simpler pattern to create new data for each test function to own and then clean that up afterwards if necessary.
-
If a fixture setup function errors, it will prevent tests that test context from running. This includes async fixtures that reject. If you need a promise from a fixture, return a closure instead.
Throw an error with a provided method if the provided value is not
true. Useful for checking intermediate steps in expect functions.
Usage: assert(value, msg)
Examples:
# Passes
expect("several checks", () => {
assert(1 + 1 === 2, "1 + 1 = 2");
assert([1, 2, 3][1] === 2, "second element is 2");
return true
})
# Fails
expect("Bad math", () => {
assert(2 + 2 === 5, "2 + 2 = 5");
return true;
})The browser-test.html file comes with a set of example tests
built in that show usage of the various features. These can be used as
a reference and/or deleted from your project.
Note: These built-in tests also serve to test the testing framework itself, so we actually expect some of them to be failing.
If you don't like the method names, renaming them is as simple as
changing the names in the destructuring from test.methods(). Just
find the lines that look like this:
const {
describe, expect, expectEqual, expectDeepEqual, expectErr
} = test.methods();And give new names to the objects. For example:
const {
describe: desc,
expect: test,
expectEqual: testEq,
expectDeepEqual: testDeep,
expectErr: testErr,
} = test.methods();Browser-test is MIT licensed. See LICENSE.txt for the full license
text. When copying browser-test.html into your project, simply leave
the license notice at the top of the file intact.