Testing PatternFly 6 Modal Components¶
Status: Active Guide Last Updated: 2025-10-09 Purpose: Comprehensive guide for testing PatternFly 6 Modal components in JSDOM environment
Table of Contents¶
- Overview
- Modal Behavior in JSDOM
- Basic Testing Pattern
- Common Testing Scenarios
- Common Issues and Solutions
- Best Practices
- Examples
- References
Overview¶
What You'll Learn¶
- How PatternFly 6 modals render in JSDOM test environment
- Proven testing patterns for modal interactions
- Solutions to common modal testing issues
- Best practices for modal accessibility testing
Key Takeaway¶
✅ PatternFly 6 modals work perfectly in JSDOM with no special workarounds needed!
Modal Behavior in JSDOM¶
DOM Structure¶
When a PatternFly 6 Modal opens, it creates the following structure:
<body class="pf-v6-c-backdrop__open">
<!-- Original content becomes hidden -->
<div aria-hidden="true">
<button>Open Modal</button>
</div>
<!-- Modal portal rendered as direct child of body -->
<div class="pf-v6-c-backdrop" id="pf-modal-part-X">
<div class="pf-v6-l-bullseye">
<div
role="dialog"
aria-modal="true"
aria-labelledby="pf-modal-part-X"
class="pf-v6-c-modal-box pf-m-md"
>
<!-- Built-in close button (X icon) -->
<div class="pf-v6-c-modal-box__close">
<button aria-label="Close">×</button>
</div>
<!-- Modal header -->
<header class="pf-v6-c-modal-box__header">
<h1 class="pf-v6-c-modal-box__title">
<span class="pf-v6-c-modal-box__title-text">Modal Title</span>
</h1>
</header>
<!-- Modal body with your content -->
<div class="pf-v6-c-modal-box__body">
<!-- Your modal content here -->
</div>
<!-- Optional modal footer -->
<footer class="pf-v6-c-modal-box__footer">
<!-- Footer buttons -->
</footer>
</div>
</div>
</div>
</body>
Portal Behavior¶
✅ Modal portals to document.body (not React root):
- Creates
<div class="pf-v6-c-backdrop">as direct child of<body> - Original page content gets
aria-hidden="true"when modal opens - Body element gets class
pf-v6-c-backdrop__openwhen modal is active - All content remains accessible to React Testing Library queries
ARIA Attributes¶
✅ All ARIA attributes render correctly:
role="dialog"- Identifies the modalaria-modal="true"- Indicates modal behavioraria-labelledby="pf-modal-part-X"- Links to modal titlearia-describedby- Optional, links to description- Built-in close button has
aria-label="Close"
Basic Testing Pattern¶
Step-by-Step Approach¶
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Modal, ModalBody, ModalHeader, Button } from '@patternfly/react-core';
it('should open and interact with modal', async () => {
// 1. Set up user event
const user = userEvent.setup();
// 2. Render component
render(<ComponentWithModal />);
// 3. Open modal
await user.click(screen.getByRole('button', { name: /open/i }));
// 4. Wait for modal to appear
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// 5. Verify modal content
expect(screen.getByText(/modal content/i)).toBeInTheDocument();
// 6. Interact with modal elements
const input = screen.getByLabelText(/name/i);
await user.type(input, 'Test Value');
// 7. Submit or close modal
await user.click(screen.getByRole('button', { name: /submit/i }));
// 8. Verify modal is closed
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
Essential Rules¶
- ✅ Always use
waitFor()- Modal rendering is async - ✅ Use
queryByRole('dialog')- To check if modal exists - ✅ Use
getByRole('dialog')- When modal should be present (throws if not found) - ✅ Set up userEvent - Use
userEvent.setup()for better async handling
Common Testing Scenarios¶
1. Opening a Modal¶
it('should open modal when button clicked', async () => {
const user = userEvent.setup();
render(<ComponentWithModal />);
// Verify modal is closed initially
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
// Click trigger button
await user.click(screen.getByRole('button', { name: /create/i }));
// Wait for and verify modal is open
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});
2. Closing a Modal¶
it('should close modal when cancel clicked', async () => {
const user = userEvent.setup();
render(<ComponentWithModal />);
// Open modal
await user.click(screen.getByRole('button', { name: /open/i }));
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Click cancel button
await user.click(screen.getByRole('button', { name: /cancel/i }));
// Verify modal is closed
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
3. Form Submission in Modal¶
it('should submit form data from modal', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<ComponentWithModal onSubmit={onSubmit} />);
// Open modal
await user.click(screen.getByRole('button', { name: /create/i }));
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Fill form
await user.type(screen.getByLabelText(/name/i), 'Test Name');
await user.type(screen.getByLabelText(/description/i), 'Test Description');
// Submit
await user.click(screen.getByRole('button', { name: /submit/i }));
// Verify submission
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
name: 'Test Name',
description: 'Test Description'
});
});
// Verify modal closed
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
4. Testing ARIA Attributes¶
it('should have proper ARIA attributes', async () => {
const user = userEvent.setup();
render(<ComponentWithModal />);
await user.click(screen.getByRole('button', { name: /open/i }));
await waitFor(() => {
const dialog = screen.getByRole('dialog');
// Verify ARIA attributes
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(
dialog.getAttribute('aria-labelledby') ||
dialog.getAttribute('aria-label')
).toBeTruthy();
});
});
5. Testing Modal Content Accessibility¶
it('should make modal content accessible', async () => {
const user = userEvent.setup();
render(<ComponentWithModal />);
await user.click(screen.getByRole('button', { name: /open/i }));
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// All content should be queryable
expect(screen.getByRole('heading', { name: /modal title/i })).toBeInTheDocument();
expect(screen.getByText(/modal description/i)).toBeInTheDocument();
expect(screen.getByLabelText(/input field/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
});
Common Issues and Solutions¶
Issue 1: Multiple Close Buttons¶
Problem: PatternFly Modal includes a built-in close button (X icon) AND your component may have custom close buttons (Cancel, Close, etc.). Both may match generic queries like /close/i.
Error:
Solutions:
Option 1: Use Exact Match for Built-in Close Button¶
// Find built-in close button by exact aria-label
const closeButton = screen.getByRole('button', { name: 'Close' })
await user.click(closeButton)
Option 2: Find Custom Button by Text Content¶
// Find all close buttons, then filter by text
const closeButtons = screen.getAllByRole('button', { name: /close/i })
const customButton = closeButtons.find((btn) => btn.textContent === 'Cancel')
await user.click(customButton)
Option 3: Use within() to Scope Query¶
import { within } from '@testing-library/react'
// Scope query to modal body only
const modal = screen.getByRole('dialog')
const modalBody = modal.querySelector('.pf-v6-c-modal-box__body')
const cancelButton = within(modalBody).getByRole('button', { name: /cancel/i })
await user.click(cancelButton)
Option 4: Use More Specific Query¶
// Query by specific button text
await user.click(screen.getByRole('button', { name: 'Cancel' }))
await user.click(screen.getByRole('button', { name: 'Submit' }))
Issue 2: Act() Warnings¶
Problem: Tests show warnings about React state updates not wrapped in act():
Analysis:
- These warnings appear when modal state updates (open/close) trigger React re-renders
- Warnings are informational only and do not cause test failures
- Tests pass successfully with proper async handling
Solution: Use waitFor() for all modal operations:
// ✅ CORRECT - waitFor handles async state updates
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// ❌ WRONG - Direct query may not wait for state update
expect(screen.getByRole('dialog')).toBeInTheDocument()
Note: You can safely ignore act() warnings in modal tests as long as you're using waitFor() and your tests are passing. The warnings don't indicate broken functionality.
Issue 3: Modal Not Found¶
Problem: screen.getByRole('dialog') fails to find modal.
Possible Causes:
1. Modal Not Open¶
Check: Verify modal state is isOpen={true}
// Debug modal state
const modal = screen.queryByRole('dialog')
console.log('Modal open:', modal !== null)
Solution: Ensure you've clicked the trigger button and waited for modal to open:
await user.click(screen.getByRole('button', { name: /open/i }))
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
2. Missing waitFor()¶
Check: Are you waiting for the modal to render?
Solution: Always use waitFor() when checking for modal:
3. Provider Issues¶
Check: Does your component need NotificationProvider or other context?
Solution: Use proper test setup with all required providers:
Issue 4: Form Validation Errors Not Showing¶
Problem: Modal form validation errors don't appear in tests.
Cause: Validation may be async or require specific user interactions.
Solution:
it('should show validation errors', async () => {
const user = userEvent.setup();
render(<ComponentWithModal />);
await user.click(screen.getByRole('button', { name: /create/i }));
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Try to submit without filling required fields
await user.click(screen.getByRole('button', { name: /submit/i }));
// Wait for validation errors
await waitFor(() => {
expect(screen.getByText(/name is required/i)).toBeInTheDocument();
});
});
Best Practices¶
1. Always Use waitFor() for Modal Operations¶
// ✅ CORRECT
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// ❌ WRONG - Race condition
expect(screen.getByRole('dialog')).toBeInTheDocument()
2. Test Modal in Isolation First¶
Create a simple test to verify basic modal behavior before testing complex interactions:
describe('Modal behavior', () => {
it('should open and close', async () => {
// Basic open/close test
})
it('should show modal content', async () => {
// Content verification
})
it('should submit form', async () => {
// Complex interaction
})
})
3. Use Specific Queries¶
// ✅ GOOD - Specific query
screen.getByRole('button', { name: 'Create API Key' })
// ❌ BAD - Too generic
screen.getByText(/create/i)
4. Clean Up Between Tests¶
describe('Modal tests', () => {
beforeEach(() => {
// Ensure clean slate
document.body.innerHTML = ''
})
// Tests here
})
5. Test Accessibility¶
Always verify ARIA attributes and keyboard navigation:
it('should be keyboard accessible', async () => {
const user = userEvent.setup();
render(<ComponentWithModal />);
// Open with Enter key
const trigger = screen.getByRole('button', { name: /open/i });
trigger.focus();
await user.keyboard('{Enter}');
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Close with Escape key
await user.keyboard('{Escape}');
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
Examples¶
Complete Modal Test Suite¶
import React, { useState } from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Modal, ModalBody, ModalHeader, Button, FormGroup, TextInput } from '@patternfly/react-core';
// Example component with modal
const CreateItemModal: React.FC<{ onSubmit: (data: any) => void }> = ({ onSubmit }) => {
const [isOpen, setIsOpen] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const handleSubmit = () => {
onSubmit({ name, description });
setIsOpen(false);
setName('');
setDescription('');
};
return (
<>
<Button onClick={() => setIsOpen(true)}>Create Item</Button>
<Modal
title="Create New Item"
isOpen={isOpen}
onClose={() => setIsOpen(false)}
variant="medium"
>
<ModalHeader title="Create New Item" />
<ModalBody>
<FormGroup label="Name" isRequired>
<TextInput
value={name}
onChange={(_, value) => setName(value)}
aria-label="Item name"
/>
</FormGroup>
<FormGroup label="Description">
<TextInput
value={description}
onChange={(_, value) => setDescription(value)}
aria-label="Item description"
/>
</FormGroup>
</ModalBody>
<footer className="pf-v6-c-modal-box__footer">
<Button variant="primary" onClick={handleSubmit}>
Submit
</Button>
<Button variant="link" onClick={() => setIsOpen(false)}>
Cancel
</Button>
</footer>
</Modal>
</>
);
};
describe('CreateItemModal', () => {
it('should open modal when Create button clicked', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<CreateItemModal onSubmit={onSubmit} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /create item/i }));
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});
it('should close modal when Cancel clicked', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<CreateItemModal onSubmit={onSubmit} />);
await user.click(screen.getByRole('button', { name: /create item/i }));
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /cancel/i }));
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
expect(onSubmit).not.toHaveBeenCalled();
});
it('should submit form with entered data', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<CreateItemModal onSubmit={onSubmit} />);
await user.click(screen.getByRole('button', { name: /create item/i }));
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
await user.type(screen.getByLabelText(/item name/i), 'Test Item');
await user.type(screen.getByLabelText(/item description/i), 'Test Description');
await user.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
name: 'Test Item',
description: 'Test Description'
});
});
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
it('should have proper ARIA attributes', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<CreateItemModal onSubmit={onSubmit} />);
await user.click(screen.getByRole('button', { name: /create item/i }));
await waitFor(() => {
const dialog = screen.getByRole('dialog');
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(
dialog.getAttribute('aria-labelledby') ||
dialog.getAttribute('aria-label')
).toBeTruthy();
});
});
it('should make all modal content accessible', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<CreateItemModal onSubmit={onSubmit} />);
await user.click(screen.getByRole('button', { name: /create item/i }));
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
expect(screen.getByLabelText(/item name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/item description/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
});
References¶
Documentation¶
Related Files¶
- Research test:
frontend/src/test/research/modal-investigation.test.tsx - Research findings:
docs/development/pf6-testing-research.md - Test utilities:
frontend/src/test/test-utils.tsx
Additional Resources¶
Last Updated: 2025-10-09 Next Review: After ApiKeysPage modal tests migration