Lesson 4 Testing with Jest - BuddyLReno/rails-stimulus-intro GitHub Wiki
⬅️ to Course Overview || 📝 Lesson 4 Feedback || 🐞 Lesson 4 Bug Report
As I've grown as a developer, I've come to realize the importance of tests and how good testing can give you confidence in your code. If you're like me, you might try to go whole hog into it and over test your stuff. I did this with Stimulus and was trying to do some nasty tricks to get direct access to the controllers to specifically call methods for testing.
Don't do that.
Stimulus isn't meant to be used directly by other javascript. It's interface is all on html and other events. We can do things with html and events to create situations that trigger our stimulus controllers and then assert on the output and results of those events. Think of it like a spaceship. We can send signals and shoot at it and all we can do is see what happens to the spaceship when we do that. Does it fire a laser? Does it blow up? We can't see what's happening inside the spaceship but we can see what it does.
Testing stimulus is just like that. Fire events and click buttons on some example html and assert the changes that happen as a result of that.
Since I've not found any real formal articles on testing Stimulus, this will just be one long walkthrough. In your terminal, if you run npx jest --verbose
, you'll see that there are a number of todo tests setup for you with Jest.
Let's walk through each test step by step. If you don't wish to do that, you can find the full file in solutions/04
.
Start by opening short_story_controller.test.js
. You'll find a number of stimulus specific things commented out. Uncomment those. Running npx jest
should still give you the same result as before. We're using a helper library called @testing-library
which has some helpful things to use when running jest tests.
Relevant docs: getByTestId | fireEvent | userEvent
Notice the file contains at the top an html variable with some example html inside.
const html = `
<div
data-controller="short-story"
data-action="keyup->short-story#validateForm">
<form
data-testid="form"
data-action="ajax:success->short-story#ajaxSuccess">
<input
type="text"
data-testid="title"
data-target="short-story.title"/>
<textarea
data-testid="textarea"
data-target="short-story.storyText">
</textarea>
<input
type="submit"
value="Submit"
data-testid="submitButton"
data-target="short-story.submitButton" />
</form>
<p
data-testid="successMessage"
data-target="short-story.successMessage">
Success!
</p>
</div>
`;
This replicates how our form is rendered on the page with all the attributes we need to setup the stimulus controller. You'll find some elements also have a data-testid
attribute. This is @testing-library
's specific method to get references to html elements so we can fire events and assert on them.
At the top of our tests is a beforeEach
method. This method is run before every single test. Before each test, we reset the html by setting the document to our example html at the top of the file. Then we tell stimulus to start itself.
Now we can write our first test.
We need to rewrite the test so it'll actually run. Currently, it will simply mark itself as todo when written as test.todo
. Change the line from:
test.todo('disables submit button');
to:
test('disables submit button', () => {
});
Since we're testing the connect
method, we don't have to do anything for this test, just need to get access to the submit button and assert that it's state is the way we want it to be when the connect method is run. Use getByTestId
to get the submit button with the data-testid
attribute and assert that it's disabled. The first parameter is the html getByTestId
will use for querying. Use document.body
. The second parameter is the string it's looking for, which in this case is submitButton
.
test('disables submit button', () => {
const button = getByTestId(document.body, 'submitButton');
expect(button.disabled).toBe(true);
});
This test will grab the button, then check that the disabled attribute is set to true! Running npx jest --verbose
in your terminal again should see this test turn green.
This test is similar to the last one, except this time we need to simulate typing into a text field has occurred. @testing-library
's solution for something like that is the userEvent
function. You'll need to:
- Get a reference to the submit button with
getByTestId
. - Get a reference to the title input
- Use
userEvent
to simulate typing into the textbox - Assert that the submit button was enabled.
Just like before, rewrite the todo
into a regular test.
test('editing title input enables submit button', () => {
});
Start by getting a reference to the the title input and the submit button, similar to the previous test.
const button = getByTestId(document.body, 'submitButton');
const title = getByTestId(document.body, 'title');
To simulate typing into the textbox you'll need to use userEvent
. There is a type method that takes two parameters. The first one is the element to type into and the second is the text to type.
userEvent.type(title, 'some title');
Now just expect that the disabled state of the submit button is set to false!
test('editing title input enables submit button', () => {
const button = getByTestId(document.body, 'submitButton');
const title = getByTestId(document.body, 'title');
userEvent.type(title, 'My title');
expect(button.disabled).toBe(false);
});
Running npx jest --verbose
should show the test turn green.
This test operates the same way as the previous test except it works with the textarea. Try on your own to figure this one out. Of course the answer is listed below here:
// answer
test('editing storyText input enabled submit button', () => {
const button = getByTestId(document.body, 'submitButton');
const textarea = getByTestId(document.body, 'textarea');
userEvent.type(textarea, 'My short story.');
expect(button.disabled).toBe(false);
});
These next set of tests will require you to emulate Rails-ujs ajax:success
event.
- Create a custom event to emuluate
ajax:success
- Get a reference to the form tag and success message elements
- fire the event from the form tag reference
- expect the success message classlist contains the
show
class.
Emulating the ajax:success
message is easy. Create a custom event and give it the same name.
const event = new CustomEvent('ajax:success', {
detail: [{ someKey: 'someValue', status: 200 }]
});
The properties used in the detail
section don't matter. Our stimulus controller expects some data available at detail[0]
so that's all this part is doing.
Grab your references to the form tag and the success message, then use fireEvent
to dispatch the custom event.
const form = getByTestId(document.body, 'form');
const successMessage = getByTestId(document.body, 'successMessage');
fireEvent(form, event);
Assert the class on the successMessage
and you're all finished! Running npx jest --verbose
should see the test turn green.
test('adds show class to successMessage', () => {
const event = new CustomEvent('ajax:success', {
detail: [{ someKey: 'someValue', status: 200 }]
});
const form = getByTestId(document.body, 'form');
const successMessage = getByTestId(document.body, 'successMessage');
fireEvent(form, event);
expect(successMessage.classList.contains('show')).toBe(true);
});
Things start to get a little tricky here. We're going to need to dive into jest mocks for this one. A jest mock is a fake function that takes the place of a real function. It keeps track of the calls that were made to it as well as what arguments were passed to it. Super useful for making sure integrations between areas work as intended! In this case, we're going to use a mock on dispatchEvent
to make sure our controller called it with the specific event we wanted our code to emit.
Create the same custom event for ajax:success
and reference to the form tag like the previous test. The next line is the key to making this test work:
document.dispatchEvent = jest.fn();
fireEvent(form, event);
We know our controller is using the dispatchEvent
from the document level so we can override the method with our jest mock. It's as simple as that! Since we don't expect anything in return from dispatchEvent
, the mock is completed. After that, we fire the event and can then do our expectation on the mocked dispatchEvent
.
This one is a little more complicated to pull off so here's the expectation and then we can walk through it.
expect(document.dispatchEvent.mock.calls[0][0].type).toBe(
'shortStoryForm:success'
);
So the .mock
property stores information about the mock and it's specifics. In this case, we're interested in the calls
property. This is an array of calls that also stores an array of arguments. calls[CALL_INDEX][ARGUMENT_INDEX]
for example. So if you had two calls to function a(arg1, arg2)
and you wanted to see the second argument the second time it was called, you would get that like this: .calls[1][1]
. .calls[1]
references the second time the code called the a
function and .calls[1][1]
would get the second argument (arg2
) from the second time the a
function was called.
I know, it sounds like a lot and it kind of is. You can read through the documentation for jest mocks here to get a better understanding of it.
The .type
in the first expect statement, gives us the name of the event that was called which is expected to be shortStoryForm:success
. The .detail
in the second expect statement, gives us access to the args that were created for the CustomEvent. We can then use .toEqual
to verify the objects are similar as expected. Putting everything together:
test('emits a shortStoryForm:success event', () => {
const event = new CustomEvent('ajax:success', {
detail: [{ someKey: 'someValue', status: 200 }]
});
const form = getByTestId(document.body, 'form');
document.dispatchEvent = jest.fn();
fireEvent(form, event);
expect(document.dispatchEvent.mock.calls[0][0].type).toBe(
'shortStoryForm:success'
);
expect(document.dispatchEvent.mock.calls[0][0].detail).toEqual({
someKey: 'someValue',
status: 200
});
});
Running npx jest --verbose
should see the test turn green!
This concludes lesson 4.