åØęµč§åØäøęµčÆ Vue ē»ä»¶
Source: Julia Evans
Hello! One of my long term projects on here is figuring out how to write frontend Javascript without using Node or any other server JS runtime.
One issue I run into a lot in my frontend JS projects is that I donāt know how to write tests for them. Iāve tried to use Playwright in the past, but it felt slow and unwieldy to be starting these new browser processes all the time, and it involved some Node code to orchestrate the tests.
The result is that I just donāt test my frontend code which doesnāt feel great. Usually I donāt update my projects much either so it doesnāt come up that much, but it would be nice to be able to make changes with more confidence! So a way to do frontend testing that I like has been on my wishlist for a long time.
idea: just run the tests in the browser tab
Alex Chan wrote a great post a while back called Testing JavaScript without a (third-party) framework in response to one of my previous posts in this series that explained how to write a tiny unit-testing framework that runs in a page in browser.
I loved this post at the time, but it only talked about unit testing and I wanted to write end-to-end integration tests for my Vue components, and I didnāt know how to do that.
So when I was talking to Marco the other day and he said something like āyou know, you can just run tests for your Vue components in the browserā, I thought āhey, I should try that again!!!ā
I just did all of this yesterday so certainly thereās a lot to improve but I wanted to write down a few things I noticed about the process before I forget.
This was a bit tricky for me because the Vue site usually assumes that youāre
using Node as part of your build process in some way (thereās a lot of āstep 1:
npm install THING), and I didnāt want to use Node/Deno/etc. But it turned
out to not be too complicated.
The project Iām going to talk about testing is this zine feedback site I wrote in 2023.
the test framework: QUnit
I used QUnit. It worked great but I donāt have anything interesting to say about how it works so Iāll leave it at that. I think that Alexās āwrite your own test frameworkā approach would have worked too. I followed these directions.
I did appreciate that QUnit has a ārerun testā button that will only rerun 1 test. Because there are so many network requests in my tests, having a way to run just 1 test makes it a lot less confusing to debug the test.
step 1: set up the component for testing
The first thing I needed to do was get my Vue components set up in the test environment.
I changed my main app to put all my components in window._components,
kind of like this:
const components = {
'Feedback': FeedbackComponent,
...
}
window._components = components;
Then I was able to write a mountComponent function which
does basically exactly the same thing my normal main app does
(render a tiny template with the component I want to use).
The only differences are:
- I can optionally pass some some extra data to use as its props.
- It mounts the component to a temporary invisible div which will get removed
from the DOM after the test is done. The div is positioned off the page
(
position: absolute; top: -10000, ...) so you canāt see it.
Hereās what using the mountComponent function looks like:
const {div} = mountComponent(
'<Page :feedbacks="feedbacks" id=2 />',
{feedbacks: [testFeedback]},
);
and hereās the code for it:
function mountComponent(template, data) {
const app = Vue.createApp({
template: template,
data: () => data,
})
for (const [c, v] of Object.entries(window._components)) {
app.component(c, v);
}
const div = document.getElementById('qunit-fixture')
.appendChild(document.createElement('div'));
return div;
}
The result is a div where I can programmatically click, fill in form data, check that the right content appears, etc.
step 2: add some fixture data
Because I was writing end-to-end integration tests to make sure my client JS worked properly with my server, I needed to have some test data in my database. So I wrote ~25 lines of SQL to set up some test data in my database, and added an endpoint to my dev server to run the SQL to reset the test data to a known state.
async function reset() {
return fetch('/api/reset_test_data', {method: "POST"})
}
Then I just run await reset() at the beginning of any test that needs the
test data.
My reset() function actually doesnāt always totally reset everything which is
kind of bad, but it was workable to start with and can always be improved.
step 3: a basic test
Hereās what a basic test looks like! Basically weāre rendering the div and make sure it contains some approximately correct data.
QUnit.test('renders feedback content', async function (assert) {
const {div} = mountComponent(
'<Page :feedbacks="feedbacks" id=2 image=2 page_hash=2 />',
{feedbacks: [testFeedback]},
);
assert.ok(div.textContent.includes('loved this section'));
})
Those are all the basic pieces! Now here are a few issues I ran into along the way
waiting for parts of the page to render
I have a lot of network requests in my tests, and it takes time for them to finish and for the Vue code to do what it has to do with the results and update the DOM.
I think we all learned a long time ago that putting random sleep() calls in
your tests and hoping that the timings are right is slow and flaky and extremely
frustrating, so I needed a different way.
As far as I can tell the normal way to deal with this is to figure out a way to tell from the DOM whether itās okay to proceed or not. Like āif this button is visible, we can ā.
So I wrote a little waitFor() function that polls every 20ms to see if a
condition has finished yet. It times out after 2 seconds.
Hereās what using it looks like:
QUnit.test("click item", async function (assert) {
const {div} = mountComponent(
'<Feedback zine_id="test123" image_width="800px" />',
{});
const item = await waitFor(() => div.querySelector('.feedback-item'));
item.click();
// rest of test goes here...
})
It looks like there are a lot of implementations of this concept out there and theyāre all better thought-through than mine. (from a quick Google: qunit-wait-for, playwright expect.poll)
figuring out the right thing to wait for is not straightforward
In some cases I thought Iād identified the right thing to wait for in the DOM (ājust wait for this textarea to appear!ā) but it turned out that because of some internal details of how my program works, actually I needed to wait for something else later on which was hard to pin down.
I ended up changing one of my components to add some random value to the DOM
when it was finished an important action (like data-this-thing-is-ready=true)
which didnāt feel great.
My best guess is that the right way to fix this kind of test issue is a refactor that also makes the app more reliable for the users: if thereās an element in the DOM that isnāt actually ready for the user to interact with, maybe I shouldnāt be displaying it yet!
adding some CSS classes to identify things (but is that right?)
I ended up adding a few classes to HTML elements that I needed to find in the tests, either because I needed to click on them or wait for them to appear in the DOM.
I might want to change this approach later - frontend testing frameworks seem to suggest avoiding using CSS classes and instead using something like getByRole or as a last resort something like a data-testid. Feels like thereās a way to make the app more accessible and easier to test at the same time.
filling out forms is tricky
To fill out a form, I canāt just set the value, I also need to dispatch an
event to tell Vue that the element has changed. For example, checkbox and
textarea need different kinds of events.
textarea.value = 'banana banana banana';
textarea.dispatchEvent(new Event('input'));
checkbox.checked = true;
checkbox.dispatchEvent(new Event('change'));
This is kind of annoying and it made me realize why I might want to use some kind of UI testing library, for example:
- Testing Libraryās example of filling out a form looks extremely different from what Iām doing
- Vue Test Utils: their section on form handling looks like it simplifies this a lot.
test coverage
I want to have an idea of what my test coverage was, and it turns out that Chrome actually has a built-in code coverage feature for JS and CSS!
My JS is bundled into a file called bundle.js with esbuild, so I could just
look at bundle.js and see which lines werenāt covered.
The process was a little finicky: I had to turn off sourcemaps in the Chrome devtools to get this to work, and thereās a specific not super obvious series of actions I have to do in order to see the coverage data.
this was so fun!
As usual with these posts Iāve never really worked as a frontend or backend developer (other than for myself!) and I feel like Iām constantly learning how to do super basic tasks.
I really had a blast doing this. My frontend projects always feel so fragile because theyāre untested, and maybe one day Iāll have a test suite Iām confident in!
Some things Iām still thinking about:
- While writing this post I found this frontend testing library called
Testing Library that has a lot of
guidelines for how to write tests that are very different from my initial ideas.
I experimented with rewriting everything to use Testing Library and it felt
pretty good, so weāll see how that goes. They distribute a
.umd.jsfile that works without Node. - Iām not sure how I feel about not having a way to run these tests on the command line at all. Maybe thereās a simple way to work primarily in the browser but have an way to run them in CI too if I want?