Testing React Component Props and State
Component testing is fundamentally about verifying that a component behaves correctly given certain inputs (props) and produces expected outputs (rendered DOM, state changes, side effects). Testing props and state in Cypress means passing different prop combinations to your component, watching how it renders and responds to user interactions, and asserting that internal state changes are reflected in the DOM.
Over the years testing React applications, I've learned that the most effective component tests focus on observable behavior (what the user sees) rather than internal state implementation details. This article covers the patterns that make those tests maintainable.
Testing Component Props
Props are the primary way to test component behavior. Mount a component with different props and verify the DOM reflects those differences:
// Component: Badge
export function Badge({ label, variant = 'default', count = 0 }) {
return (
<span className={`badge badge--${variant}`} data-testid="badge">
{label} {count > 0 && <strong>({count})</strong>}
</span>
)
}
// Tests
describe('Badge Component', () => {
it('renders with label', () => {
mount(<Badge label="Notifications" />)
cy.get('[data-testid="badge"]').should('contain', 'Notifications')
})
it('applies variant class based on prop', () => {
mount(<Badge label="Status" variant="success" />)
cy.get('[data-testid="badge"]').should('have.class', 'badge--success')
})
it('displays count when provided', () => {
mount(<Badge label="Messages" count={5} />)
cy.get('[data-testid="badge"]').should('contain', '(5)')
})
it('hides count when zero', () => {
mount(<Badge label="Messages" count={0} />)
cy.get('[data-testid="badge"]').should('not.contain', '(')
})
})
Each test isolates a single prop behavior. This makes failures specific: if the variant test fails, you know it's about variant handling, not rendering in general.
Testing Default Props and Fallbacks
Verify that components work with default props:
// Component: Dropdown with sensible defaults
export function Dropdown({
items = [],
selected = null,
onSelect = () => {},
placeholder = 'Select an option',
}) {
return (
<select value={selected} onChange={(e) => onSelect(e.target.value)}>
<option value="">{placeholder}</option>
{items.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
)
}
// Test
it('uses default placeholder when not provided', () => {
mount(<Dropdown />)
cy.get('select').should('contain', 'Select an option')
})
it('uses custom placeholder', () => {
mount(<Dropdown placeholder="Choose..." />)
cy.get('select').should('contain', 'Choose...')
})
it('renders empty when no items provided', () => {
mount(<Dropdown />)
cy.get('option').should('have.length', 1) // Only the placeholder
})
Testing Controlled Inputs (React State)
For components with internal state managed by props, mount with initial values and verify state changes are reflected:
// Component: ControlledInput
export function ControlledInput({
value,
onChange,
placeholder = 'Enter text',
maxLength = 100,
}) {
return (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
maxLength={maxLength}
data-testid="input"
/>
)
}
// Test
it('calls onChange when text is entered', () => {
const handleChange = cy.stub()
mount(
<ControlledInput value="" onChange={handleChange} />
)
cy.get('[data-testid="input"]').type('hello')
// onChange should be called for each character
cy.wrap(handleChange).should('have.been.calledWith', 'h')
cy.wrap(handleChange).should('have.been.calledWith', 'hello')
})
it('respects maxLength prop', () => {
mount(
<ControlledInput value="" onChange={() => {}} maxLength={5} />
)
// HTML input enforces maxLength
cy.get('[data-testid="input"]').type('hello world')
cy.get('[data-testid="input"]').should('have.value', 'hello')
})
Testing Hooks and Internal State
For components with internal state (useState, useReducer, etc.), test the observable behavior, not the state variable itself:
// Component: Counter with hook
export function Counter({ min = 0, max = 10 }) {
const [count, setCount] = useState(0)
const increment = () => {
if (count < max) setCount(count + 1)
}
const decrement = () => {
if (count > min) setCount(count - 1)
}
return (
<div>
<p data-testid="count-display">{count}</p>
<button data-testid="increment-btn" onClick={increment}>
+
</button>
<button data-testid="decrement-btn" onClick={decrement}>
-
</button>
</div>
)
}
// Test — verify state changes through DOM
it('increments and decrements count', () => {
mount(<Counter min={0} max={10} />)
// Initially at 0
cy.get('[data-testid="count-display"]').should('contain', '0')
// Click increment
cy.get('[data-testid="increment-btn"]').click()
cy.get('[data-testid="count-display"]').should('contain', '1')
// Click decrement
cy.get('[data-testid="decrement-btn"]').click()
cy.get('[data-testid="count-display"]').should('contain', '0')
})
it('respects min and max bounds', () => {
mount(<Counter min={-1} max={1} />)
// Start at 0, can increment to 1
cy.get('[data-testid="increment-btn"]').click()
cy.get('[data-testid="count-display"]').should('contain', '1')
// Cannot increment beyond max
cy.get('[data-testid="increment-btn"]').click()
cy.get('[data-testid="count-display"]').should('contain', '1')
// Can decrement to -1
cy.get('[data-testid="decrement-btn"]').click()
cy.get('[data-testid="decrement-btn"]').click()
cy.get('[data-testid="count-display"]').should('contain', '-1')
// Cannot decrement beyond min
cy.get('[data-testid="decrement-btn"]').click()
cy.get('[data-testid="count-display"]').should('contain', '-1')
})
Never directly assert on count state variable; instead, verify the DOM shows the correct value.
Testing useEffect and Async Operations
Test components that perform side effects:
// Component: UserProfile with useEffect
export function UserProfile({ userId }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
setUser(data)
setLoading(false)
})
.catch((err) => {
setError(err.message)
setLoading(false)
})
}, [userId])
if (loading) return <p data-testid="loading">Loading...</p>
if (error) return <p data-testid="error">Error: {error}</p>
if (!user) return <p>No user</p>
return (
<div>
<h1 data-testid="user-name">{user.name}</h1>
<p data-testid="user-email">{user.email}</p>
</div>
)
}
// Test
it('loads and displays user', () => {
cy.intercept('GET', '/api/users/1', {
statusCode: 200,
body: { id: 1, name: 'Alice', email: '[email protected]' },
}).as('getUser')
mount(<UserProfile userId={1} />)
// Initially loading
cy.get('[data-testid="loading"]').should('exist')
// After fetch, user is displayed
cy.wait('@getUser')
cy.get('[data-testid="user-name"]').should('contain', 'Alice')
cy.get('[data-testid="user-email"]').should('contain', '[email protected]')
})
it('displays error on fetch failure', () => {
cy.intercept('GET', '/api/users/1', { statusCode: 500 }).as('getUser')
mount(<UserProfile userId={1} />)
cy.wait('@getUser')
cy.get('[data-testid="error"]').should('contain', 'Error')
})
Testing Context and Global State
For components that consume Context or Redux, wrap them in providers before mounting:
// Component: UserGreeting (uses Context)
const UserContext = createContext()
export function UserGreeting() {
const { user } = useContext(UserContext)
return <h1>Hello, {user?.name}!</h1>
}
// Test with mock context
it('displays user from context', () => {
const mockUser = { id: 1, name: 'Bob' }
const Wrapper = ({ children }) => (
<UserContext.Provider value={{ user: mockUser }}>
{children}
</UserContext.Provider>
)
mount(<UserGreeting />, { wrapper: Wrapper })
cy.get('h1').should('contain', 'Hello, Bob!')
})
Or create a custom mount command (covered in article 8) that applies providers automatically.
Testing Props Changes Over Time
Use cy.mount() within a test to remount with different props:
it('updates when props change', () => {
const { rerender } = mount(<Counter max={5} />)
cy.get('[data-testid="increment-btn"]').click()
cy.get('[data-testid="increment-btn"]').click()
cy.get('[data-testid="count-display"]').should('contain', '2')
// Change max prop
rerender(<Counter max={1} />)
// Now incrementing is blocked at 1
cy.get('[data-testid="increment-btn"]').click()
cy.get('[data-testid="count-display"]').should('contain', '2')
})
The rerender function (returned by mount) lets you update props without remounting.
Verifying Side Effects with Spy
Use cy.stub() and cy.spy() to verify your component calls callbacks correctly:
// Component: DeleteButton
export function DeleteButton({ itemId, onDelete }) {
return (
<button onClick={() => onDelete(itemId)}>
Delete Item
</button>
)
}
// Test
it('calls onDelete with correct itemId', () => {
const handleDelete = cy.stub()
mount(<DeleteButton itemId={42} onDelete={handleDelete} />)
cy.get('button').click()
cy.wrap(handleDelete).should('have.been.calledWith', 42)
})
Key Takeaways
- Test components by mounting them with different props and verifying the DOM output changes accordingly.
- For components with internal state, verify state changes through observable DOM changes, not by directly inspecting state variables.
- Use stubs and spies to verify that callbacks are called with correct arguments.
- Test hooks by mounting components that use them and asserting on the rendered result.
- For async operations (useEffect with fetch), use Cypress intercept to mock API calls and verify the component handles loading, success, and error states.
- For components that need global state or context, wrap them in providers or use custom mount commands.
Frequently Asked Questions
How do I test a component that uses multiple hooks?
Mount the component and test the observable behavior of all hooks combined. For example, a component with useState and useEffect should be tested by verifying it starts with the initial state, then shows loading, then displays the fetched data—all through DOM assertions.
Can I directly inspect a component's internal state in Cypress?
Technically yes (via cy.window()), but don't. It breaks encapsulation and makes tests fragile. Instead, verify state through the DOM. If you can't verify behavior through the DOM, it's a sign your component lacks feedback (missing feedback element, error message, etc.).
Should I test all possible prop combinations?
No, test the important combinations: defaults, edge cases, and meaningful variations. For example, if a component has 5 boolean props, test the combinations that affect behavior, not every permutation (2^5 = 32).
How do I test a component that renders conditionally based on state?
Mount the component, trigger the state change through user interaction, then assert the conditional content appears. For example, test a modal by clicking "open", verifying the modal DOM exists, then clicking "close" and verifying it's gone.
Can I access a component instance or its methods directly in Cypress?
No. Cypress tests run in the browser, but you access the DOM, not component instances. This is intentional—it enforces testing through the component's public interface (props, DOM), not private implementation.