A Vue 3 component's entire contract is emitting a `submit` event with the form payload — it renders no result of its own. How do you test that with Vue Testing Library, and does asserting on emitted events contradict its user-centric principle?
answer
- two audiences: the user and the parent
- props in, events out
- recorded emissions, name to argument lists
- the Vue shape of a callback prop
- how you triggered it matters more
basics
~20 sDrive the real form through its accessible controls, then assert on the recorded event: the render result exposes emitted(), keyed by event name with the arguments of each emission. An emitted event is the component's public contract with its parent, not an internal detail.
solid answer
~50 sFill in the fields and click the submit control using accessible queries, then check what came out. Vue Testing Library's `render()` returns an `emitted()` function whose result is keyed by event name, with an array of emissions and their arguments — so `emitted().submit[0][0]` is the payload of the first `submit`. It does not contradict the philosophy. The principle is "assert on the contract, not on the internals", and for a Vue component the emitted event *is* the contract offered to its parent — the direct analogue of calling a callback prop. What would violate the principle is reaching into the instance to check the internal form state instead. In Vue 3 you can also pass a spy as an `onSubmit` prop, since listeners arrive as `onXxx` props, which reads well when you want the assertion inline. Either way the interaction must go through the real UI, or you have only tested that emit works.
go deeper
Know that the render result records what the component emitted, keyed by event name, and that you produce the event by interacting with the real controls rather than calling anything on the component.
Explain why an emitted event counts as public contract while internal state does not, and get the shape right: event name to a list of emissions, each holding the arguments. Mention the Vue 3 option of passing a spy as an onXxx prop.
Demonstrate the judgment call: which emissions deserve a pinned contract test, when rendering the parent and asserting the visible outcome is the stronger test, and how you avoid a suite that freezes incidental chatter between components you own.
Frame it as API design. Decide which components are boundaries whose events are versioned contracts worth locking down, and make sure the test suite reflects that boundary rather than testing every emission everywhere at equal cost.
## The apparent contradiction The Testing Library family's slogan is that tests should resemble how software is used. A component that emits an event and renders nothing new looks like a counterexample: a user cannot perceive an emission. Does the philosophy leave you stranded? No — because the slogan is shorthand for a sharper rule: *assert on the component's contract, not on its internals*. A component has two audiences. The person using the app perceives rendered output. The parent component consuming it perceives props in, events out. Both are public surfaces. What is private is how the component stores intermediate state, which lifecycle hook it uses, and how many refs it holds. In React the same component takes an `onSubmit` callback prop, and asserting that the callback was called with the right payload has never been controversial. Vue's emitted event is the same contract with different syntax, so it is tested the same way. ## The mechanics The render result exposes `emitted()`, which comes from the Vue Test Utils layer underneath. Its return value is an object keyed by event name; each value is an array of emissions, and each emission is the array of arguments passed to `emit`: ```js import { render, fireEvent } from '@testing-library/vue' import ContactForm from './ContactForm.vue' test('emits submit with the entered values', async () => { const { getByRole, emitted } = render(ContactForm) await fireEvent.update(getByRole('textbox', { name: 'Email' }), '[email protected]') await fireEvent.click(getByRole('button', { name: 'Send' })) expect(emitted()).toHaveProperty('submit') expect(emitted().submit).toHaveLength(1) expect(emitted().submit[0][0]).toEqual({ email: '[email protected]' }) }) ``` The nesting trips people up: `submit[0]` is the first emission, `submit[0][0]` its first argument. Asserting the length matters as much as the payload — a double-submit bug shows up as two entries and nothing else in the test would notice. ## The spy alternative Because Vue 3 exposes listeners as `onXxx` props, you can pass a spy straight in: ```js const onSubmit = vi.fn() const { getByRole } = render(ContactForm, { props: { onSubmit } }) await fireEvent.click(getByRole('button', { name: 'Send' })) expect(onSubmit).toHaveBeenCalledTimes(1) expect(onSubmit).toHaveBeenCalledWith({ email: '[email protected]' }) ``` This reads more like the React equivalent and gives you the spy assertions your runner already provides. Choose either; the important part is what comes before the assertion. ## The part that actually decides test quality The assertion style matters far less than how you produced the event. Two tests can both check `emitted().submit` and be worlds apart: - The weak one calls something on the instance to force the emit, or dispatches an event on a node found by CSS class. It proves that `emit` works, which is Vue's job, not yours. - The strong one types into the real inputs found by their labels and clicks the real button found by its accessible name. It exercises validation, disabled states, the `@submit.prevent` wiring, and the payload assembly — and it fails if the submit button is unreachable or unlabelled. So the discipline is: interact like a user, assert on the contract. ## The honest limits Asserting on emissions is right when the event genuinely is the contract. It becomes a smell in two situations. First, when the emitted event is *internal chatter* — a component emitting a low-level `input-changed` that only itself and a tightly-coupled sibling care about. Testing that pins down a detail you may want to refactor. Second, when the emission is only half the story: if the parent's reaction is what matters — a dialog closing, a list gaining a row — then a test that renders the parent and asserts on the visible outcome is stronger than one that stops at the emission. A reasonable rule: test emissions at the boundary of a reusable component whose parents are many and unknown; test visible outcomes when the parent-child pair is a single feature and you can render them together cheaply. ## What an interviewer listens for The good answer distinguishes contract from internals rather than reciting the slogan, names how the emissions are recorded and shaped, and states the boundary — that an emission is worth asserting when it is a public API, not when it is incidental noise between two components you own.
- You assert `emitted().submit[0][0]` matches the payload. What else about that recording is worth asserting?The number of emissions. `emitted().submit` is an array, so asserting its length catches a double-fire from a button that both submits the form and runs a click handler — a real bug that a payload-only assertion sails past. It is also worth asserting a `submit` is absent when validation should have blocked it, using the absence of the key rather than an empty array.
- When would you test the parent's visible reaction instead of the child's emitted event?When the parent-child pair is one feature rather than a reusable boundary. If the emission's whole purpose is to close a dialog or add a row, rendering the parent and asserting the row appeared tests the wiring too — a correct emission connected to a mistyped listener name still fails there, while a child-only test passes. Reserve emission assertions for components with many unknown parents.
- Would you assert on a v-model update event the same way?Yes, and it is one of the clearest cases: `update:modelValue` is unambiguously public API, since v-model on the parent depends on the exact event name and payload. Drive the real input and assert the recorded emission carries the new value. Renaming that event is a breaking change for every consumer, which is precisely what a contract test should catch.
saying these in an interview costs you the question
- Calls asserting on emitted events an implementation-detail test
- Triggers the emit through the component instance instead of the UI
- Checks only the payload, never how many times it fired
- Confuses the nesting of emissions and their arguments
- Treats every internal event as worth pinning down