A Signup component is a registration form: an email field, a create account button, and an error message when the email is wrong. A component under test is a rendered piece of interface. You can check it through two lenses: from inside, as a programmer who knows about useState and props, or from outside, as a user who sees a field and presses a button. The lens you pick decides whether the tests survive a refactor.
The naive move is to reach inside. Pull out the component's state, assert that an isValid variable turned false, find a node by className and confirm a div.error appeared. It feels natural: the data is right there. It looks like you are testing exactly what you wrote.
It breaks at the first refactor. Rename the class, swap useState for useReducer, move the error into another component - the behavior for the user is unchanged, yet the tests go red. The reverse happens too, and it is more dangerous: a test asserts on className and passes even though the button does not actually click and the error message is invisible to a screen reader. The test is green, the interface is broken.
Testing Library turns the lens around. Its idea: the more a test resembles the way the software is actually used, the more confidence it gives. So you do not peek at state - you work with what is available to a user: element roles and labels. The test stops knowing about internals and starts describing behavior: typed a wrong email, pressed the button, saw an error.
This needs an environment and a set of tools. jsdom is a browser DOM in pure Node, so the component has somewhere to render without a browser; it comes from the jest-environment-jsdom environment. @testing-library/react provides render, which mounts the component. @testing-library/jest-dom adds matchers such as toHaveTextContent and toBeVisible. user-event simulates user actions. The matchers are wired in once in a setup file.
The primary query is getByRole: it finds an element by its role in the accessibility tree, and the name option filters by accessible name, for example getByRole('button', { name: /create account/i }). For form fields getByLabelText is convenient: a user finds a field by its label. This doubles as an accessibility check - if a query by role or label finds nothing, a screen reader will not see the element either.
Actions are performed by userEvent. First userEvent.setup creates a user session, then await user.type enters text and await user.click presses the button - both asynchronous, because behind a single action stands a chain of focus and keyboard events. The error appearing after submit is caught with findByRole('alert'): this query returns a promise and retries until the node shows up.
The act warning grows from the same soil. Jest complains not wrapped in act(...) when React state updates outside its awareness - usually a sign you did not await an async action or query. The good news: userEvent and findBy already wrap updates in act themselves, so an honest await makes the warning disappear. There is no need to silence it with a random act wrapper - that treats the symptom, not the cause.
The price of the approach is discipline in markup: for the test to find elements by role and label, the interface must be accessible. But this is not a tax, it is a bonus - the same code becomes more usable for real people. And the payoff shows in the failing scenario from the start: a className test would stay silent about a broken button, whereas a test through the user's eyes fails exactly when the user cannot press the button.
npm i -D jest-environment-jsdom @testing-library/react \
@testing-library/jest-dom @testing-library/user-event
// test/setup.ts
import '@testing-library/jest-dom'test('shows an error for an invalid email', async () => {
const user = userEvent.setup()
render(<Signup />)
await user.type(screen.getByLabelText(/email/i), 'wrong')
await user.click(screen.getByRole('button', { name: /create account/i }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'Enter a valid email',
)
})