Testing Preact/React Portals with Testing Library

Nick Taylor - Nov 30 '20 - - Dev Community

This post was going to be about troubles I ran into testing Portals, but in the end after writing three quarters of this post, the problems I thought I had were not problems and I ended up simplifying my tests. 🙃

Those test refinements are in

Cleaned up tests I wrote for #11525 #11685

What type of PR is this? (check all applicable)

  • [x] Refactor
  • [ ] Feature
  • [ ] Bug Fix
  • [ ] Optimization
  • [ ] Documentation Update

Description

This is just a little test refinement for the work in #11525

Related Tickets & Documents

#11525, #10424

QA Instructions, Screenshots, Recordings

N/A. This is just tests being refactored.

UI accessibility concerns?

N/A

Added tests?

  • [x] Yes
  • [ ] No, and this is why: please replace this line with details on why tests have not been included
  • [ ] I need help with writing tests

Added to documentation?

[optional] Are there any post deployment tasks we need to perform?

[optional] What gif best describes this PR or how it makes you feel?

Potentially a GI JOE figurine that looks like Tom Selleck

Regardless, it's still a good run down of how to test Portals.

At Forem, the software that powers DEV, we use Preact, sprinkled throughout the application, where it makes sense. The reason being, is that the application is a Rails application and for the most part we are serving up content in the form of blog posts, listings etc. via server-side rendering.

Typically these “Preact”ified interactions are for the logged on user, but there are other spots we use it too. One of those spots is search. The way search works is, initially the search form is server-side rendered (SSR) and then the Preact Search component mounts itself in the same spot. Preact’s Virtual DOM (VDOM) is smart enough to compare the DOM even on the initial render and only change things if necessary. This prevents flickering.

So the search text box is now a Preact component once the page is completely loaded. When a user enters a search query and then presses the ENTER key, Instant Click will make an AJAX call that grabs the search results based on what the user is searching for. Instant Click is a whole other topic, but feel free to read up on it.

In a nutshell, it converts a server-side rendered application into a single page application (SPA) like application. This is important to note as it’s an integral part of our story about Preact portals.

So we get our search results via AJAX and the page’s main area is updated. In the case of search, this is a search results page. Up until now, this has worked like clockwork.

My coworker Pawel has a pull request up that adds a new search form that is for mobile/smaller screens. When on mobile/smaller screens, the search text box in the top navigation gets hidden and the mobile one becomes visible. For more on that check out the PR below (it will probably be merged by the time you are reading this post)

Updating navigation (especially mobile) #10424

What type of PR is this? (check all applicable)

  • [x] Refactor
  • [x] Feature
  • [ ] Bug Fix
  • [ ] Optimization
  • [ ] Documentation Update

Description

This PR does some shuffling in our main navigation and introduces updates to mobile navigation.

QA Instructions, Screenshots, Recordings

Video: https://d.pr/v/yzdZF8

Added tests?

  • [ ] yes
  • [ ] no, because they aren't needed
  • [ ] no, because I need help

Added to documentation?

  • [ ] docs.forem.com
  • [ ] readme
  • [x] no documentation needed

Pawel, ran into some issues synchronizing the main search form (larger screens) with the smaller one that is contained within the search results. Right away this screamed, use a portal since it is an element that renders in a different DOM element, i.e. a Portal's container.

An Excalidraw drawing showing the different parts of the search page rendered

I reworked things so that there was now a parent component that managed the state of the original search text box and the mobile search text box that gets rendered within the search results using the useState hook. I did some initial tests in Pawel’s PR and it seemed to work, but on subsequent searches it stopped working.

And then it clicked. Portals are the right approach, but when new search results are rendered, a new search form for mobile view is rerendered from the server-side (via Instant Click magic), i.e. the DOM element is destroyed and recreated. Not to be confused with React updating the state of a component.

So typing in the mobile view stopped synching the search term between search text boxes because the search text box created by the portal got wiped out by the server-side render.

An Excalidraw drawing showing the different parts of the search page rendered with a new search result

Once I figured that out, I got all the moving parts working. Check out my PR as it contains more information in the comments about this.

Prep work to sync mobile search in #10424 #11525

What type of PR is this? (check all applicable)

  • [x] Refactor
  • [ ] Feature
  • [ ] Bug Fix
  • [ ] Optimization
  • [ ] Documentation Update

Description

This PR introduces synching of search forms. This will be required for #10424 which introduces a search form in the mobile experience.

-----------------------------------------------------------|---------|----------|---------|---------|------------------------------------------------------
File                                                       | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s                                    
-----------------------------------------------------------|---------|----------|---------|---------|------------------------------------------------------
All files                                                  |   42.79 |    39.35 |   41.66 |   43.13 |                                                      
  ...
  SearchFormSync.jsx                                       |     100 |       90 |     100 |     100 | 41 

The odd thing is line 45 is covered, so not sure what's up, but I'm confident with these tests.

Related Tickets & Documents

#10424

QA Instructions, Screenshots, Recordings

Search on the site should behave exactly as it currently does.

UI accessibility concerns?

There are no UI changes, just some shuffling of Preact components, but no actual rendered markup has changed.

Once this is merged, it will be generating new markup, but it will be another search form which currently has no accessibilty concerns as far as I'm aware.

Added tests?

  • [x] Yes
  • [ ] No, and this is why: please replace this line with details on why tests have not been included
  • [ ] I need help with writing tests

Added to documentation?

[optional] Are there any post deployment tasks we need to perform?

Smile

[optional] What gif best describes this PR or how it makes you feel?

A kitten sitting on a cloud shooting lightning from their paws

Alright, so now the component and portal work great in the actual application. With all that context under out belts lets discuss testing out this component with preact-testing-library, one of the testing libraries in the Testing Library family.

If you’re using preact-testing-library or react-testing-library, the APIs are the same. If you’re interested you can see what’s available in the API. We’re going to focus on the render function for the time being.

Typically you test a component like this. Note that you can choose what to destructure from the result of the render function based on what’s available in the API for your needs. We are going to go with a function that finds a DOM element by its label text.

it('should synchronize search forms', async () => {
    const { findByLabelText } = render(<SearchFormSync />);

    // Only one input is rendered at this point because the synchSearchForms custom event is what
    // tells us that there is a new search form to sync with the existing one.
    const searchInput = await findByLabelText('search');

    // Because window.location has no search term in it's URL
    expect(searchInput.value).toEqual('');
});
Enter fullscreen mode Exit fullscreen mode

The test above does the following:

  1. Render the <SearchFormSync /> component and make the findByLabelText function available by destructuring it from the result of the render function.
  2. Next, we want to find an element that has an HTML <label /> or one of the ARIA attributes for a label, for example aria-label.
  3. From there, a built in jest common matcher is used to assert that our search textbook is initialized with an empty string, expect(searchInput.value).toEqual('');

At this point there is nothing out of the ordinary about this test. And everything passes.

 PASS  app/javascript/Search/__tests__/SearchFormSync.test.jsx
  <SearchFormSync />
    ✓ should synchronize search forms (19 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.751 s, estimated 2 s
Ran all test suites related to changed files.

Watch Usage: Press w to show more.
Enter fullscreen mode Exit fullscreen mode

Alright, let’s continue with our testing. So next up we want to ensure that both the desktop and mobile search forms render the same. Under the hood, the way it works is when a search result is returned, the search results include the mobile search form and have a little snippet of JS that emits a custom event to synchronize the forms.

<div id="mobile-search-container">
  <form
    accept-charset="UTF-8"
    action="/search"
    method="get"
  >
    <input
      name="utf8"
      type="hidden"
      value="✓"
    />
    <input
      aria-label="search"
      autocomplete="off"
      class="crayons-header--search-input crayons-textfield"
      name="q"
      placeholder="Search..."
      type="text"
    />
  </form>
</div>
...
<script>
  // ... some other search related code

  // A custom event that gets dispatched to notify search forms to synchronize their state.
  window.dispatchEvent(new CustomEvent('syncSearchForms', { detail: { querystring: location.search } }));
</script>
Enter fullscreen mode Exit fullscreen mode

So in our test we need to do a few things:

  1. Simulate the search results URL
// simulates a search result returned which contains the server side rendered search form for mobile only.
setWindowLocation(`https://locahost:3000/search?q=${searchTerm}`);
Enter fullscreen mode Exit fullscreen mode
  1. Have a DOM element available for the portal’s container.
// This part of the DOM would be rendered in the search results from the server side.
// See search.html.erb.
document.body.innerHTML =
  '<div id="mobile-search-container"><form></form></div>';
Enter fullscreen mode Exit fullscreen mode
  1. Emit the custom event
fireEvent(
  window,
  new CustomEvent('syncSearchForms', {
    detail: { querystring: window.location.search },
  }),
);
Enter fullscreen mode Exit fullscreen mode

From there we need to assert that the search forms are in sync.

    const [desktopSearch, mobileSearch] = await findAllByLabelText('search');

    expect(desktopSearch.value).toEqual(searchTerm);
    expect(mobileSearch.value).toEqual(searchTerm);
Enter fullscreen mode Exit fullscreen mode

Let's put that all together.

describe('<SearchFormSync />', () => {
  beforeEach(() => {
    // This part of the DOM would be rendered in the search results from the server side.
    // See search.html.erb.
    // It is where the portal will render.
    document.body.innerHTML =
      '<div id="mobile-search-container"><form></form></div>';

    setWindowLocation(`https://locahost:3000/`);

    global.InstantClick = jest.fn(() => ({
      on: jest.fn(),
      off: jest.fn(),
      preload: jest.fn(),
      display: jest.fn(),
    }))();
  });

  it('should synchronize search forms', async () => {
    const { findByLabelText, findAllByLabelText } = render(<SearchFormSync />);

    // Only one input is rendered at this point because the synchSearchForms custom event is what
    // tells us that there is a new search form to sync with the existing one.
    const searchInput = await findByLabelText('search');

    // Because window.location has no search term in it's URL
    expect(searchInput.value).toEqual('');

    // https://www.theatlantic.com/technology/archive/2012/09/here-it-is-the-best-word-ever/262348/
    const searchTerm = 'diphthong';

    // simulates a search result returned which contains the server side rendered search form for mobile only.
    setWindowLocation(`https://locahost:3000/search?q=${searchTerm}`);

    fireEvent(
      window,
      new CustomEvent('syncSearchForms', {
        detail: { querystring: window.location.search },
      }),
    );

    const [desktopSearch, mobileSearch] = await findAllByLabelText('search');

    expect(desktopSearch.value).toEqual(searchTerm);
    expect(mobileSearch.value).toEqual(searchTerm);
  });
});
Enter fullscreen mode Exit fullscreen mode

Let's rerun the tests.

 PASS  app/javascript/Search/__tests__/SearchFormSync.test.jsx
  <SearchFormSync />
    ✓ should synchronize search forms (31 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.326 s
Ran all test suites matching /sync/i.

Watch Usage: Press w to show more.
Enter fullscreen mode Exit fullscreen mode

Awesome, so the original search form (desktop search) and the new search form (mobile/smaller screens) render properly.

Let's take a look at what happens under the hood by looking at preact-testing-library's render function

function render (
  ui,
  {
    container,
    baseElement = container,
    queries,
    hydrate = false,
    wrapper: WrapperComponent
  } = {}
) {
  if (!baseElement) {
    // Default to document.body instead of documentElement to avoid output of potentially-large
    // head elements (such as JSS style blocks) in debug output.
    baseElement = document.body
  }

  if (!container) {
    container = baseElement.appendChild(document.createElement('div'))
  }
...
Enter fullscreen mode Exit fullscreen mode

There is an optional options parameter which we can see here destructured.

{
  container,
  baseElement = container,
  queries,
  hydrate = false,
  wrapper: WrapperComponent
} = {}
Enter fullscreen mode Exit fullscreen mode

In our case we're not using these so based on the code, we have no baseElement option set since we are not passing it in and its default value is the container option which is undefined since we did not pass one in. So, the baseElement in our case is document.body.

Since we have no container defined, it gets set to baseElement.appendChild(document.createElement('div')) which is a <div /> appended to the document.body. Remember from our test set up, we added the portal container DOM element via

// This part of the DOM would be rendered in the search results from the server side.
// See search.html.erb.
document.body.innerHTML =
  '<div id="mobile-search-container"><form></form></div>';
Enter fullscreen mode Exit fullscreen mode

So before our test runs, this is what the document.body looks like

<body>
  <div
    id="mobile-search-container"
  >
    <!-- This is where our portal will be rendered -->  
    <form />
  </div>
  <!-- This is where our component will be rendered -->
  <div>
  </div>
</body>
Enter fullscreen mode Exit fullscreen mode

Let's use preact-testing-library's debug so that we can see the successful test rendered as HTML.

To use debug(), we need to add it to the destructured functions like so:

const { debug, findByLabelText, findAllByLabelText } = render(<SearchFormSync />);
Enter fullscreen mode Exit fullscreen mode

Alright, now let's add the debug() call to the test.

describe('<SearchFormSync />', () => {
  beforeEach(() => {
    // This part of the DOM would be rendered in the search results from the server side.
    // See search.html.erb.
    // It is where the portal will render.
    document.body.innerHTML =
      '<div id="mobile-search-container"><form></form></div>';

    setWindowLocation('https://locahost:3000/');

    global.InstantClick = jest.fn(() => ({
      on: jest.fn(),
      off: jest.fn(),
      preload: jest.fn(),
      display: jest.fn(),
    }))();
  });

  it('should synchronize search forms', async () => {
    const { debug, findByLabelText, findAllByLabelText } = render(<SearchFormSync />);

    // Only one input is rendered at this point because the synchSearchForms custom event is what
    // tells us that there is a new search form to sync with the existing one.
    const searchInput = await findByLabelText('search');

    // Because window.location has no search term in it's URL
    expect(searchInput.value).toEqual('');

    // https://www.theatlantic.com/technology/archive/2012/09/here-it-is-the-best-word-ever/262348/
    const searchTerm = 'diphthong';

    // simulates a search result returned which contains the server side rendered search form for mobile only.
    setWindowLocation(`https://locahost:3000/search?q=${searchTerm}`);

    fireEvent(
      window,
      new CustomEvent('syncSearchForms', {
        detail: { querystring: window.location.search },
      }),
    );

    const [desktopSearch, mobileSearch] = await findAllByLabelText('search');
    debug();
    expect(desktopSearch.value).toEqual(searchTerm);
    expect(mobileSearch.value).toEqual(searchTerm);
  });
});
Enter fullscreen mode Exit fullscreen mode

The test runs again successfully, but now we also have some outputted markup from the rendering.

 PASS  app/javascript/Search/__tests__/SearchFormSync.test.jsx
  <SearchFormSync />
    ✓ should synchronize search forms (43 ms)
    ✓ should synchronize search forms on a subsequent search (9 ms)

  console.log
    <body>
      <div
        id="mobile-search-container"
      >
        <form
          accept-charset="UTF-8"
          action="/search"
          method="get"
        >
          <input
            name="utf8"
            type="hidden"
            value="✓"
          />
          <input
            aria-label="search"
            autocomplete="off"
            class="crayons-header--search-input crayons-textfield"
            name="q"
            placeholder="Search..."
            type="text"
          />
        </form>

      </div>
      <div>
        <form
          accept-charset="UTF-8"
          action="/search"
          method="get"
        >
          <input
            name="utf8"
            type="hidden"
            value="✓"
          />
          <input
            aria-label="search"
            autocomplete="off"
            class="crayons-header--search-input crayons-textfield"
            name="q"
            placeholder="Search..."
            type="text"
          />
        </form>
      </div>
    </body>

      at debug (node_modules/@testing-library/preact/dist/pure.js:97:15)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.516 s
Ran all test suites matching /sync/i.

Watch Usage: Press w to show more.
Enter fullscreen mode Exit fullscreen mode

So from the outputted markup, we see that the original form rendered (desktop) and the mobile search form also rendered in the portal container <div id="mobile-search-container" />.

Using debug() in preact-testing-library or react-testing-library is super handy if you run into rendering issues.

And that's it! To recap, we had a component that also rendered a portal and we tested that the original component and the portal both rendered.

Until next time folks!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .