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.

✅ Setup

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.

✅ #connect - disables submit button

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.

✅ #validateForm - editing title enables submit button

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.

✅ #validateForm - editing textarea enables submit button

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);
    });

✅ #ajaxSuccess - Add show class to successMessage

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);
});

✅ #ajaxSuccess - emits a shortStoryForm:success event

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.

⚠️ **GitHub.com Fallback** ⚠️