If you find yourself struggling with excessive mocking and hard to maintain tests consider integration testing your application. Some JS developers find their integration tests give them more leverage than unit tests. Therefore they subvert a traditional test pyramid and write more integration tests than unit tests.
One popular option for integration testing your app is DOM emulation with jsdom and polyfills/test doubles for various browser APIs such as fetch, localStorage, or EventSource. This approach allows you to write Node.js tests for your frontend code without spinning a browser.
jsdom
based setup tradeoffs:
- (+) easy to run from CLI without extra tooling
- (+) faster tests in CI server than spinning-up browser tests
- (-) moderately slow startup time for the first test
- (-) can't find browser discrepancies
Another option is to run your tests in a real browser. With this approach, all APIs just work, and you can inspect a failing test with your DevTools, but at the expense of a slower and heavier test environment.
Browser-based setup tradeoffs:
- (+) testing a real browser
- (+) easy to inspect the environment after a failing tests
- (+) all browser APIs just work
- (-) cleaning the environment between tests is cumbersome
- (-) requires complex tooling to run in the CI server
- (-) with many tests it's slower than
jsdom
setup because of the rendering overhead
We're going to focus on using jsdom for integration testing in this book.
Mocha tests can run in both Node.js and a browser. Not all test runners have this capability.
Update package.json:
{
"type": "module",
"scripts": {
"start": "snowpack dev",
"build": "snowpack build",
"format": "prettier --write 'src/**/*.js'",
"test": "mocha -r jsdom-global/register --recursive \"src/**/*.test.js\""
},
"dependencies": {
"hyperapp": "2.0.14",
"hyperapp-fx": "2.0.0-beta.2",
"hyperlit": "0.3.6"
},
"devDependencies": {
"@testing-library/dom": "7.30.3",
"eventsource": "1.1.0",
"jsdom": "16.5.3",
"jsdom-global": "3.0.2",
"localstorage-polyfill": "1.0.1",
"mocha": "8.3.2",
"prettier": "2.2.1",
"snowpack": "3.2.2",
"unfetch": "4.2.0"
}
}
We're adding missing polyfills for:
- DOM
- fetch
- EventSource
- localStorage (used later) Please note how we're telling mocha to include jsdom on startup.
Write a test in test/App.test.js:
const { assert } = chai;
const { getAllByTestId, waitFor } = TestingLibraryDom;
import { start } from "../src/App.js";
const container = () => document.getElementById("app");
describe("App", () => {
beforeEach(function () {
container().innerHTML = "";
});
it("Load initial posts", async () => {
start();
await waitFor(() => {
assert.strictEqual(getAllByTestId(container(), "item").length, 10);
});
});
});
The code cleans up the app container before every test. It's essential to start each test with a clean slate.
Always prefer beforeEach
over afterEach
for cleanup as you may need to inspect a failing test every now and then.
afterEach
would erase useful debugging information.
Inside a test, start a new instance of the app. You'll build the App()
function in the next step.
The test waits for ten items to show up.
getAllByTestId(container(), "item")
is a utility querying for data-testid="item"
inside a container()
.
waitFor
is a utility that waits until:
- the assertion doesn't throw any errors
waitFor
times out- mocha times out
Change src/App.js:
import { app } from "hyperapp";
import { init, subscriptions, view } from "./Posts.js";
// bootstrap
export const App = () =>
app({
init,
view,
subscriptions,
node: document.getElementById("app"),
});
You defer the app initialization so that it can be started anew in each test. Now the app is broken. Let's fix it before you go back to the tests.
Add src/Start.js:
import { start } from "./App.js";
start();
Refer to this file from src/index.html:
<script type="module" src="src/Start.js"></script>
Check if the app is working.
Going back to tests. Add the test data attribute to the listItem
view fragment in src/Posts.js:
const listItem = (post) => html`
<li key=${post.id} data-key=${post.id} data-testid="item">
...
</li>
`;
You'll use data attributes such as data-testid
to make the tests independent of the DOM structure. It will keep your test stable even if you have to rewrite particular tags e.g. because of a new design.
Before you start integration testing, make sure that backend API is running. Go to: http://hyperapp-api.herokuapp.com/api/post. Heroku hosting puts the app to sleep if it hasn't been used recently. Opening the URL should wake it up.
Add a test for the post submission and waiting for the SSE notification:
const {
getAllByTestId,
waitFor,
fireEvent,
findByTestId
} = testingLibrary;
const container = () => document.getElementById("app");
describe("App", () => {
const sendMessage = async (newMessage) => {
const input = await findByTestId(container(), "post-input");
input.value = newMessage;
fireEvent.input(input);
const button = await findByTestId(container(), "add-post");
button.click();
};
it("Add a post as an anonymous user", async function () {
stop = App();
await waitFor(() => {
assert.strictEqual(getAllByTestId(container(), "item").length, 10);
});
await sendMessage("random sentence " + Date.now());
await waitFor(() => {
assert.strictEqual(getAllByTestId(container(), "item").length, 11);
});
});
});
The test starts with a new app to make it independent of the other tests.
With the app started, the test is:
- checking items before a message gets sent
- creating a new random message
- sending the message to the server - @testing-library simplifies triggering DOM events such as typing a text. It also provides DOM queries that wait for DOM elements to appear in the UI.
- waiting for the message to show up at the top of the posts list
Put a test data attribute in Posts.js:
<input
data-testid="post-input"
type="text"
oninput=${[UpdatePostText, targetValue]}
value=${state.currentPostText}
autofocus
/>
Check if both tests are green. They shouldn't be. It's your task to fix it.
Add data-testid="add-post"
to the "Add Post" button.
Solution
<button onclick=${AddPost} data-testid="add-post">Add Post</button>
Check if both tests are green.
Your integration test execution time and reliability are heavily dependent on the API response time and availability. There are at least two options to make your tests faster and more reliable:
- record all HTTP traffic with a library like PollyJS. The first time you run the tests, it intercepts all network calls and saves them to localStorage or file system. Afterward, it can replay the traffic much faster than the original API. When the API changes, you make a new recording.
- inject a fake implementation of all effects and subscriptions you want to replace. The trick is to move all effects and subscriptions to the entry point of your application. In production you would need to start your app with real effects/subscriptions passed as arguments:
import { start } from "./App.js";
import { Http } from "./lib/Http.js";
import { EventSourceListen } from "./lib/EventSource.js";
import { WithGuid } from "./lib/Guid.js";
start({ Http, EventSourceListen, WithGuid });
In your tests, you can provide a fake implementation of those effects/subscriptions. This technique requires minor code changes. Each module with effects needs to expose a function for injecting them. It is essentially what some people call a dependency injection—a fancy name for passing arguments to functions.
We leave it to the reader to experiment with those techniques.