testing-library / svelte-testing-library

:chipmunk: Simple and complete Svelte DOM testing utilities that encourage good testing practices
https://testing-library.com/docs/svelte-testing-library/intro
MIT License
615 stars 33 forks source link

Why does `rerender` destroy the component? #261

Closed mcous closed 7 months ago

mcous commented 1 year ago

I've been using svelte-testing-library for a few months now on a new Svelte project, and I'm enjoying it! Thanks for all your hard work.

I ran into an issue today that I wanted to highlight, because it seems to me like it should be a relatively normal thing to want to test with this library: changes to prop values trigger correct behaviors in already rendered components.

Overview

As a test author, I'd like to test that my component reacts properly to props changes by, for example, updating existing DOM elements. However, because rerender unmounts the component, DOM elements queried before the rerender are removed from the document and component state is lost.

It seems like #210 may address this issue, but it hasn't been reviewed. I think any change to rerender in this regard would also be a breaking change to the API.

Example

I am writing a component that puts a message in a role=status area. Per WAI, the procedure to test this is:

  1. Check that the container destined to hold the status message has a role attribute with a value of status before the status message occurs.
  2. Check that when the status message is triggered, it is inside the container.
  3. Check that elements or attributes that provide information equivalent to the visual experience for the status message (such as a shopping cart image with proper ALT text) also reside in the container.

The component looks something like:

<script lang="ts">
export let isUnsaved: boolean
</script>

<p
  role="status"
  aria-label="update status"
>
  {#if isUnsaved}
    <Icon name="information" />
    Unsaved changes
  {/if}
</p>

The test that I wrote naively using rerender looks like this:

it('should display an unsaved changes message if unsaved', () => {
  const { rerender } = render(UpdateStatus, { isUnsaved: false })
  const status = screen.getByRole('status', { name: 'update status' })

  expect(status).toHaveTextContent('')

  rerender({ isUnsaved: true })

  expect(status).toHaveTextContent(/unsaved changes/iu)
});

Expected behavior

The test passes

Actual behavior

The test fails, because after the rerender, the status DOM element still has no text content, because it was unmounted and a new one was created.

Workarounds / alternatives considered

I've tried out the following alternatives, none of which feel super ideal. I'd be curious if you had any others!

Re-query elements

The test will pass if I re-query the DOM elements. I dislike this approach because it does not satisfy the recommended test procedure above.

it('should display an unsaved changes message if unsaved', () => {
  const { rerender } = render(UpdateStatus, { isUnsaved: false })
  let status = screen.getByRole('status', { name: 'update status' })

  expect(status).toHaveTextContent('')

  rerender({ isUnsaved: true })
  status = screen.getByRole('status', { name: 'update status' })

  expect(status).toHaveTextContent(/unsaved changes/iu)
});

Interact with Svelte component directly

I can update props on the Svelte component directly. I dislike this approach because:

it('should display an unsaved changes message if unsaved', async () => {
  const { component } = render(UpdateStatus, { isUnsaved: false })
  const status = screen.getByRole('status', { name: 'update status' })

  expect(status).toHaveTextContent('')

  await act(() => {
    component.$set({ isUnsaved: true })
  })

  expect(status).toHaveTextContent(/unsaved changes/iu)
});

Create a wrapper component for testing

I can create a wrapper testing component that updates the prop on a button click (or similar). I dislike this approach because it bloats my test suite and adds a layer of misdirection between my test and the component that I'm actually testing.

<!-- UpdateStatus.spec.svelte-->
<script lang="ts">
import UpdateStatus from '../UpdateStatus.svelte'

let isUnsaved = false
</script>

<button
  data-testid="trigger-unsaved"
  on:click={() => (isUnsaved = true)}
/>
<UpdateStatus {isUnsaved} />
it('should display an unsaved changes message if unsaved', async () => {
  const user = userEvent.setup();

  render(UpdateStatusSpec);

  const triggerUnsavedButton = screen.getByTestId('trigger-unsaved')
  const status = screen.getByRole('status', { name: 'update status' })

  expect(status).toHaveTextContent('');

  await user.click(triggerUnsavedButton)

  expect(status).toHaveTextContent(/unsaved changes/iu);
});
mcous commented 7 months ago

Resolved by #210, currently available on @testing-library/svelte@next