Browse lessons

BONUS LESSON

CSS Selectors for Tests

Whenever we test web pages, we need to interact with CSS selectors. That is true of LiveView as well as more traditional end-to-end testing libraries like Wallaby and Hound.

In this lesson, I want to talk about which CSS selectors to target in our tests to make them resilient to change.

Let’s first talk briefly about what CSS selectors are, and then we’ll talk about choosing good CSS selectors for our tests.

What are CSS selectors?

CSS selectors are a pattern of elements (for example, h1) and other terms (for example, .users) that tell the browser which element to apply styles to.

They are used for CSS, which is why they’re called CSS selectors. But they’re also a great way for tests to target elements on a page.

Terms in CSS selectors

So what are valid terms for CSS selectors?

  • Type (or element) selectors: h1
  • Class selectors: .users, .posts
  • ID selectors: #title (should be unique on a page)
  • Attribute selectors: [href] or [href="/users"] or [href~="users"]
  • Helpful pseudo classes: :disabled, :checked
  • Combinators:
    • Descendant: h1 .class (note the space)
    • Child: body > p

To learn more, see MDN’s guide on CSS selectors and W3C’s selectors guide.

CSS selectors in practice

Let’s see CSS selectors in practice.

I’ll put a <style> tag in the render/1 function and give elements a red border color to see how we’re targeting them.

Let’s first target the h1 element:

h1 {
  border: 2px solid red;
}

Now, let’s target a class:

.post {
  border: 2px solid red;
}

Let’s target an id:

#post-37 {
  border: 2px solid red;
}

Let’s target an attribute:

[data-role='like-count'] {
  border: 2px solid red;
}

And now, if we want to target the like count for a given post, we can use a descendant combinator:

#post-37 [data-role='like-count'] {
  border: 2px solid red;
}

Choosing CSS selectors for our tests

When choosing CSS selectors in our tests, we should remember we’re not the only people in our team touching HTML.

That means HTML can change for reasons unrelated to tests.

The question is: which changes should break our tests?

The answer varies based on what we’re trying to ensure, but these are typically true:

  • Changing styling (for example, .active -> .selected) should not break tests.
  • Changing structure (table -> grid/flex) should not break tests.
  • Changing position in page usually should not break tests (unless position is part of behavior).
  • Removing an element from page should break tests.

The list is not exhaustive, but that’s what you should think about when choosing selectors.

Ask yourself: when would my test break if I use this selector?

For those reasons, my preferred approach is:

  • Target specific elements with IDs when they are unique.
  • Use semantic data attributes (for example, data-role) for repeated elements.

For example, from timeline_live_test.exs:

assert has_element?(view, "#post-#{updated_post.id} [data-role=like-count]", "3")

Our CSS selector is #post-37 [data-role='like-count']:

  • We target by ID (#post-37) because it uniquely identifies the post.
  • We use a descendant selector with data-role (not a direct child selector), because we don’t care about exact nesting.
  • We use semantic data-role so meaning stays stable even if classes/layout shift.

Alternatives and trade-offs

Could we do these instead?

  • div [data-role=like-count]: brittle if container element changes from div to span.
  • #post-37 .post-action-count: brittle if class names are refactored.
  • .post-action > .post-action-count: brittle if one extra wrapper div is introduced.

So, as a rule of thumb:

  • Use IDs when you can uniquely identify an element.
  • Use semantic data-role (or similar data attrs) to zoom in.
  • Prefer descendant combinators for robust targeting inside a component.

More resources