Functional testing with Symfony can be verbose when working with the DOM. This library provides an expressive, auto-completable, fluent wrapper around Symfony's native DomCrawler with intelligent selector resolution and a chainable assertion API:
public function testViewPostAndComments()
{
$dom = new Dom($this->client->request('GET', '/posts/3'));
$dom->assert()
->contains('My First Post')
->hasElement('h1')
->hasElementCount('#comments li', 2)
->fieldEquals('Search', '')
->fieldChecked('Subscribe')
->fieldSelected('Category', 'Technology')
;
// find elements with intelligent selector resolution
$title = $dom->findOrFail('h1');
$title->text(); // "My First Post"
// form elements are automatically typed
$select = $dom->findOrFail('Category')->ensure(Combobox::class);
$select->selectedText(); // "Technology"
}Combine this library with zenstruck/browser to get the full fluent browser testing experience:
public function testViewPostAndAddComment()
{
$this->browser()
->visit('/posts/3')
->assertSuccessful()
->assertSeeIn('h1', 'My First Post')
->fillField('Comment', 'Great post!')
->click('Submit')
->assertSeeIn('#comments', 'Great post!')
;
}composer require zenstruck/dom --dev
Note
For assertions, zenstruck/assert is required: composer require --dev zenstruck/assert.
use Zenstruck\Dom;
/** @var \Zenstruck\Dom $dom */
$dom = new Dom($html); // string, Crawler, or Response
// FINDING ELEMENTS
$dom->find('h1'); // Node|null - first matching element
$dom->findOrFail('h1'); // Node - throws if not found
$dom->findAll('li'); // Nodes - collection of matching elements
// SELECTOR RESOLUTION (strings are auto-detected in this order)
$dom->find('.my-class'); // 1. CSS selector
$dom->find('Submit'); // 2. Button text/value/aria-label
$dom->find('Click here'); // 3. Link text/aria-label/title
$dom->find('Logo'); // 4. Image alt text
$dom->find('main-content'); // 5. Element ID
$dom->find('email'); // 6. Field name attribute
$dom->find('Email Address'); // 7. Field label, aria-label or placeholder
// EXPLICIT SELECTORS (force a specific type)
$dom->find(Selector::css('.my-class'));
$dom->find(Selector::xpath('//div[@class="foo"]'));
$dom->find(Selector::id('main-content'));
$dom->find(Selector::button('Submit'));
$dom->find(Selector::link('Click here'));
$dom->find(Selector::image('Logo'));
$dom->find(Selector::testId('checkout')); // by data-testid attribute
$dom->find(Selector::field('email')); // by name or label
$dom->find(Selector::fieldForName('email')); // by name only
$dom->find(Selector::fieldForLabel('Email')); // by label, aria-labelledby, aria-label or placeholder
$dom->find(Selector::clickable('Submit')); // buttons first, then links (incl. <summary> and role="button"/"link")
// SEPARATOR SYNTAX (inline type forcing)
$dom->find('css:==:.my-class');
$dom->find('xpath:==://div[@class="foo"]');
$dom->find('id:==:main-content');
$dom->find('button:==:Submit');
$dom->find('link:==:Click here');
$dom->find('field:==:email');
$dom->find('testid:==:checkout');
// CALLBACK SELECTORS
$dom->find(function (Dom $dom): ?Node {
return $dom->find('ul')?->children()->first();
});Every matched element is returned as a Zenstruck\Dom\Node (or a more specific subclass
for form elements):
/** @var \Zenstruck\Dom\Node $node */
// TRAVERSAL
$node->parent(); // immediate parent Node or null
$node->ancestors(); // all ancestor Nodes
$node->children(); // direct child Nodes
$node->siblings(); // sibling Nodes
$node->next(); // next sibling Node or null
$node->previous(); // previous sibling Node or null
$node->closest('form'); // closest ancestor matching selector
$node->descendant('.item'); // first descendant matching selector
$node->descendants('.item'); // all descendants matching selector
// CONTENT
$node->text(); // full text content (including children)
$node->directText(); // only the node's own text
$node->outerHtml(); // the node's outer HTML
$node->innerHtml(); // the node's inner HTML
// INTROSPECTION
$node->tag(); // tag name (e.g. "div", "input")
$node->id(); // value of id attribute or null
$node->isVisible(); // visibility check
$node->isInert(); // element or an ancestor has the inert attribute
$node->attributes(); // Attributes object
$node->attr('title'); // attribute value or null
$node->data('user-id'); // data-* attribute value or null (data-user-id)
$node->hasClass('active'); // has class
// TYPE GUARDS
$node->is(Checkbox::class); // true/false
$node->ensure(Checkbox::class); // returns typed node or throwsfindAll() and traversal methods return a Zenstruck\Dom\Nodes collection:
/** @var \Zenstruck\Dom\Nodes $nodes */
$nodes->count(); // number of matched nodes
$nodes->first(); // first Node or null
$nodes->last(); // last Node or null
$nodes->filter('.active'); // narrow down with a selector
$nodes->text(); // concatenated text of all nodes
$nodes->html(); // concatenated outer HTML
$nodes->map(fn(Node $n) => $n->text()); // map to array
foreach ($nodes as $node) {
// iterable
}Nodes are automatically resolved to their specific form element type:
/** @var \Zenstruck\Dom $dom */
// INPUT
$input = $dom->findOrFail(Selector::field('email'))->ensure(Input::class);
$input->value(); // current value
$input->type(); // "text", "email", "password", etc.
// TEXTAREA
$textarea = $dom->findOrFail(Selector::field('bio'))->ensure(Textarea::class);
$textarea->value();
// CHECKBOX
$checkbox = $dom->findOrFail(Selector::field('terms'))->ensure(Checkbox::class);
$checkbox->isChecked();
// RADIO
$radio = $dom->findOrFail(Selector::field('gender'))->ensure(Radio::class);
$radio->isSelected();
$radio->selected(); // the selected Radio node
$radio->selectedValue();
// COMBOBOX (single select)
$select = $dom->findOrFail(Selector::field('country'))->ensure(Combobox::class);
$select->selectedOption(); // Option node
$select->selectedValue();
$select->selectedText();
$select->availableOptions(); // all Option nodes
$select->optionMatching('Canada'); // Option by value or text, or null
// MULTISELECT
$multi = $dom->findOrFail(Selector::field('roles'))->ensure(Multiselect::class);
$multi->selectedOptions(); // array of Option nodes
$multi->selectedValues();
$multi->selectedTexts();
// FILE
$file = $dom->findOrFail(Selector::field('photo'))->ensure(File::class);
$file->isMultiple();
// BUTTON
$button = $dom->findOrFail(Selector::button('Submit'))->ensure(Button::class);
$button->type(); // "submit", "button", "reset"
$button->value();
// COMMON FIELD METHODS (all fields inherit from Field)
$field->name(); // name attribute
$field->value(); // current value
$field->label(); // associated Label node or null
$field->isDisabled();
$field->isRequired();
$field->isReadonly();
$field->form(); // parent Form node
// FORM
$form = $dom->findOrFail('form')->ensure(Form::class);
$form->fields(); // all field Nodes
$form->buttons(); // all button Nodes
$form->submitButtons(); // submit-type buttons only
$form->submitButton(); // first submit buttonAll assertion methods return $this for chaining:
/** @var \Zenstruck\Dom $dom */
$dom->assert()
// TEXT CONTENT
->contains('some text') // page contains text
->doesNotContain('some text') // page does not contain text
->containsIn('h1', 'some text') // element contains text
->doesNotContainIn('h1', 'some text') // element does not contain text
// ELEMENT PRESENCE
->hasElement('nav') // element exists
->doesNotHaveElement('nav') // element does not exist
->hasElementCount('ul li', 5) // exact count
// VISIBILITY
->elementIsVisible('#modal') // element is visible
->elementIsNotVisible('#modal') // element is not visible
// ATTRIBUTES
->attributeContains('body', 'class', 'dark') // attribute contains value
->attributeDoesNotContain('body', 'class', 'light')
->attributeEquals('html', 'lang', 'en') // first matching element's attribute equals value
->attributeDoesNotEqual('html', 'lang', 'fr')
// CLASSES
->hasClass('body', 'dark') // element has class
->doesNotHaveClass('body', 'light') // element does not have class
// FORM FIELDS
->fieldEquals('Username', 'kevin') // field value equals
->fieldDoesNotEqual('Username', 'john') // field value does not equal
->fieldChecked('Remember me') // checkbox checked or radio selected
->fieldNotChecked('Remember me') // checkbox not checked
->fieldSelected('Role', 'Admin') // option is selected
->fieldNotSelected('Role', 'Guest') // option is not selected
->fieldIsDisabled('Username') // field has disabled attribute
->fieldIsEnabled('Username')
->fieldIsRequired('Username') // field has required attribute
->fieldIsOptional('Username')
->fieldIsReadonly('Username') // field has readonly attribute
->fieldIsNotReadonly('Username')
;Note
XPath case folding is ASCII-only. The translate() function used for case-insensitive
matching only handles A-Z. Non-ASCII characters are not case-normalized (XPath 1.0 limitation).
Note
isVisible() performs basic checks only. It detects hidden attributes, type="hidden"
inputs, and inline display:none/visibility:hidden styles. It does not evaluate CSS
stylesheets or inherited styles.
vendor/bin/phpunit