Using Playwright instead of a JSDOM Testing Environment
In my last post, I talked about a problem I had running the tests for my Vanilla JavaScript application. I was rendering the page-under-test in a JSDOM environment, and the tests were crashing when the code tried to use a browser feature that JSDOM doesn't emulate. I was able to get around this issue the first time it happened by injecting a NodeJS utility into the JSDOM environment.
Since then, I've continued to run into similar problems. It turns out that JSDOM doesn't emulate as much of the browser as I thought.
Running tests in a "realistic" environment
At the time I solved the first issue, I was running my tests against the source code files. As you may remember, I write my scripts with TypeScript, not JavaScript. When I run the app, Parcel transpiles the TypeScript files into JavaScript, and then rewrites the HTML files to reference the JavaScript files. However, when I run the tests and render my HTML into JSDOM, it fetches the source TypeScript files and runs them as-is without traspiling them.
That worked fine until I actually started using TypeScript syntax in my scripts. At first they contained only valid JavaScript, so they could be run without transpilation. When I added a type hint on a variable, JSDOM started crashing when I ran the tests because the TypeScript syntax wasn't valid JavaScript. Despite the fact that they lived in ".ts" files, JSDOM would just run them assuming they were JavaScript.
OK, so the test environment is still not realistic enough. I decided the tests should run against the bundled and transpiled files instead of the source code. That's what I'm actually serving to users, so doing this would make sure transpilation and bundling didn't cause problems. The change in file structure that happens when the code is bundled made this much harder than I expected.
To make it work, I had to get the file path of the HTML file-to-be-rendered so I could read it and build a JSDOM object from it. This involved using a well-known hack to get the path of the file that called the render function , and changing that path to match the path to the transpiled JavaScript file. This solution worked but it's hacky and complicated, and it's specific to how Parcel changes the file structure when bundling. Below is the code I ended up with to get the file path.
the following is a JavaScript code sample
function getTranspiledFilePathAndUrl(fileName: string) {
let stack = new Error().stack?.split("\n");
if (!stack) {
throw new Error("Could not get calling file path");
}
// stacktrace array's 4th entry is " at <file path>", so we remove the leading " at "
const absolutePath = stack[3].slice(
stack[3].indexOf("/"),
stack[3].lastIndexOf("/")
);
const filePathStart = absolutePath.substring(0, absolutePath.indexOf("/src"));
const publicPath = "/src/public";
const indexOfEnd = absolutePath.indexOf(publicPath) + publicPath.length;
const filePathEnd = absolutePath.substring(indexOfEnd);
const testDirectoryPublicPath = `/.test-dist/${publicPath}`;
const filePath = `${fileUrl}${filePathEnd}`;
return {
filePath: path.resolve(filePath, fileName),
fileUrl: path.resolve(fileUrl, fileName)
};
}
I don't like this. It's hard to read. It also assumes my bundled code has a specific file structure, which Parcel could choose to change at any time. For the added complexity, I don't get much more value. The tests are still running in a NodeJS environment rather than a browser. Still, it worked so I forged ahead.
Module scripts
Despite appearances, I'm not messing around with my dev environment for its own sake, I'm building a Yacht Dice web app. Next, I wanted to animate dice rolling when the user rolls the dice. I thought a simple, first-pass implementation could be to update the dice values on the page to random numbers in quick succession. The server also generates random integers, so I already have a function to do this that would work in the browser. I'll need to share code between the frontend and the server eventually, so I decided to tackle it now.
In a webapp framework, I would be able to just import the function and use it. Without one, I can still do
that thanks to "module" scripts. Rather than having my HTML load both the main dice script and the one
containing the random number generator, I can import the random integer function into the dice script. The
only caveat is that I have to specify that this is a module script in my HTML using the following syntax:
<script type="module" ...>
Not knowing if this would work, I added an import to my script and ran the app. It worked great, no fiddling needed! ...that is until I ran the tests and JSDOM threw an exception. It turns out that JSDOM doesn't support module scripts. This is apparently because of a limitation in NodeJS. It hasn't been addressed since 2018 and it won't be any time soon.
Unlike with fetch, there's no workaround that I could find for my particular project configuration. JSDOM won't support my use case, and I can't easily add module script support to JSDOM. I also can't forgo a basic feature like module scripts just because my testing environment doesn't support it. The testing environment should support feature development, not the other way around.
Now what?
It's become clear that JSDOM doesn't support enough browser features for me to use it to test my application. I'm only doing a few simple things with my code so far, and I've already run into 3 big issues. Even if I found a way to support module scripts, I'm sure I would run into more issues like this down the road.
One option is to add a web app framework like React to my project. React Testing Library includes all the infrastructure to render components in a JSDOM environment and do the things I want to do like importing utility functions. I chose not to do this (yet), because my main goal with this project is to learn more about how web applications work without frameworks like React.
The next best solution is to stop messing around and fully commit to running my tests in a real browser. JSDOM was meant to simulate a browser at the cost of some real features, but with the benefit of speed when running the tests. I've spent hours trying to make it work, so that's turned into a higher cost for a smaller benefit than I initially thought.
Playwright
I translated my tests from Testing Library into Playwright to see how well that would work. Playwright's testing utilities focus on user experience and accessibility just like Testing Library's, so the conversion was pretty simple. In fact, I deleted many more lines than I changed or added. I was able to stop using Mock Service Worker entirely - Playwright loads documents by calling the real server, but it also provides its own way to mock fetch requests for specific tests. You can browse the complete changeset required to replace Testing Library with Playwright on Github.
I did have to do a little fiddling. For example, Playwright's configuration includes an option to specify servers to run before starting the tests. You can even tell it that it should wait for a successful response from a specific endpoint. This option didn't seem to work for me. The Playwright tests would sometimes run and fail before the server was ready. I had to add an endpoint that waits for a few seconds before responding to make sure Playwright waits long enough before starting the tests. That's a pretty brittle solution - the amount of time it takes will vary from my computer to another, which might force me to come up with a better solution if I want to run my tests on a continuous integration server.
Despite little challenges like this, adding Playwright to my project was very easy and the tests run reliably and quickly! Even with a few seconds' delay at the beginning, they take about as long as my Testing Library tests did. It's very possible that they will slow down faster than a Node test suite would as I add tests to the suite. Tests always become slow as they grow, so I think either way I'll need to find a way to split them into groups and choose how and when I run which ones.
For now, Playwright is all upside. I can still write high-quality tests that care about the code's behavior rather than its implementation. The tests run against a real browser, so I can be sure my code will work for my users. And I can stop wasting hours trying to make JSDOM behave like a browser.