Testing HTML With Modern CSS

A long time ago, I wrote a reasonably popular bit of open source code called REVENGE.CSS (the caps are intentional). You should know upfront, this hasn’t been maintained for years and if I ever did get round to maintaining it, it would only be to add the “No Maintenance Intended” badge. Alas, that would technically count as maintenance.

Anyway, I was recently reminded of its existence because, curiously, I was contacted by a company who were looking to sponsor its development. Nothing came of this, which is the usual way these impromptu side quests go. But it got me thinking again about CSS-based testing (testing HTML integrity using CSS selectors) and what recent advancements in CSS itself may have to offer.

In a nutshell, the purpose of REVENGE.CSS is to apply visual regressions to any markup anti-patterns. It makes bad HTML look bad, by styling it using a sickly pink color and the infamous Comic Sans MS font. It was provided as a bookmarklet for some time but I zapped that page in a Marie Kondo-inspired re-platforming of this site.

The selectors used to apply the vengeful styles make liberal use of negation, which was already available *squints at commit history* about 11 years ago?

Here are a few rules pertaining to anchors:

a:not([href]), a[href=""], a[href$="#"], a[href^="javascript"] {...}

Respectively, these cover anchors that

  1. Don’t have href attributes (i.e. don’t conventionally function as links and are not focusable by keyboard)
  2. Have an empty href attribute
  3. Have an href attribute suffixed with a # (an unnamed page fragment)
  4. Are doing some bullhonky with JavaScript, which is the preserve of <button>s

Since I released REVENGE.CSS, I did some more thinking about CSS-based testing and even gave a 2016 talk about it at Front Conference Zurich called “Test Driven HTML”.

One of the things I recommended in this talk was the use of an invalid CSS ERROR property to describe the HTML shortcomings. This way, you could inspect the element and read the error in developer tools. It’s actually kind of neat, because you get a warning icon for free!

crossed out error property with the text "you screwed up here" followed by a yellow warning symbol

You can even restyle this particular ERROR property in your Chrome dev tools (to remove the line-through style, for starters) should you wish.

For context, REVENGE.CSS previously used pseudo-content to describe the errors/anti-patterns on the page itself. As you might imagine, this came up against a lot of layout issues and often the errors were not (fully) visible. Hence diverting error messages into the inspector.

Custom properties

In 2017, a year or so after the Zurich conference, we would get custom properties: a standardized way to create arbitrary properties/variables in CSS. Not only does this mean we can now define and reuse error styling, but we can also secrete error messages without invalidating the stylesheet:

:root {
  --error-outline: 0.25rem solid red;
}

a:not([href]) {
  outline: var(--error-outline);
  --error: 'The link does not have an href. Did you mean to use a <button>?';
}

Of course, if there are multiple errors, only one would take precedence. So, instead, it makes sense to give them each a unique—if prefixed—name:

a[href^="javascript"] {
  outline: var(--error-outline);
  --error-javascript-href: 'The href does not appear to include a location. Did you mean to use a <button>?';
}

a[disabled] {
  outline: var(--error-outline);
  --error-anchor-disabled: 'The disabled property is not valid on anchors (links). Did you mean to use a <button>?';
}

Now both errors will show up upon inspection in dev tools.

Expressive selectors

Since 2017, we’ve benefited from a lot more CSS selector expressiveness. For example, when I wrote REVENGE.CSS, I would not have been able to match a <label> that both

Now I can match such a thing:

label:not(:has(:is(input,output,textarea,select))):not([for]) {
  outline: var(--error-outline);
  --error-unassociated-label: 'The <label> neither uses the `for` attribute nor wraps an applicable form element'
}

By the same token, I can also test for elements that do not have applicable parents or ancestors. In this case, I’m just using a --warning-outline style, since inputs outside of <form>s are kind of okay, sometimes.

input:not(form input) {
  outline: var(--warning-outline);
  --error-input-orphan: 'The input is outside a <form> element. Users may benefit from <form> semantics and behaviors.'
}

(Side note: It’s interesting to me that :not() allows you to kind of “reach up” in this way.)

Cascade layers

The specificity of these testing selectors varies wildly. Testing for an empty <figcaption> requires much less specificity than testing for a <figure> that doesn’t have an ARIA label or a descendant <figcaption>. To ensure all the tests take precedence over normal styles, they can be placed in the highest of cascade layers.

@layer base, elements, layout, theme, tests;

To ensure errors take precedence over warnings we’re probably looking at declaring error and warning layers within our tests.css stylesheet (should we be maintaining just one). Here is how that might look for a suite of <figure> and <figcaption> tests:

@layer warnings {

  figure[aria-label]:not(:has(figcaption)) {
    outline: var(--warning-outline);
    --warning-figure-label-not-visible: 'The labeling method used is not visible and only available to assistive software';
  }

  figure[aria-label] figcaption {
    outline: var(--warning-outline);
    --warning-overridden-figcaption: 'The figure has a figcaption that is overridden by an ARIA label';
  }
  
}

@layer errors {

  figcaption:not(figure > figcaption) {
    outline: var(--error-outline);
    --error-figcaption-not-child: 'The figcaption is not a direct child of a figure';
  }

  figcaption:empty {
    padding: 0.5ex; /* give it some purchase */
    outline: var(--error-outline);
    --error-figcaption-empty: 'The figcaption is empty';
  }

  figure:not(:is([aria-label], [aria-labelledby])):not(:has(figcaption)) {
    outline: var(--error-outline);
    --error-no-figure-label: 'The figure is not labeled by any applicable method';
  }
  
  figure > figcaption ~ figcaption {
    outline: var(--error-outline);
    --error-multiple-figcaptions: 'There are two figcaptions for one figure';
  }
  
}

Testing without JavaScript?

Inevitably, some people are going to ask “Why don’t you run these kinds of tests with JavaScript? Like most people already do?”

There’s nothing wrong with using JavaScript to test JavaScript and there’s little wrong with using JavaScript to test HTML. But given the power of modern CSS selectors, it’s possible to test for most kinds of HTML pattern using CSS alone. No more elem.parentNode shenanigans!

As a developer who works visually/graphically, mostly in the browser, I prefer seeing visual regressions and inspector information to command line logs. It’s a way of testing that fits with my workflow and the technology I’m most comfortable with.

I like working in CSS but I also think it’s fitting to use declarative code to test declarative code. It’s also useful that these tests just live inside a .css file. Separation of concerns means you can use the tests in your development stack, across development stacks, or lift them out into a bookmarklet to test any page on the web.

I’m a believer in design systems that do not provide behavior (JavaScript). Instead, I prefer to provide just styles and document state alongside them. There are a few reasons for this but the main one is to sublimate the design system away from JS framework churn. The idea of shipping tests with the component CSS written in CSS sits well with me.

How I use this in client work

I had a client for whom I was auditing various sites/properties for accessibility. In the process, I identified a few inaccessible patterns that were quite unique to them and not something generic tests (like those that make up the Lighthouse accessibility suite) would identify.

One of these patterns was the provision of breadcrumb trails unenclosed by a labeled <nav> landmark (as recommended by the WAI). I can identify any use of this pattern with the following test:

ol[class*="breadcrumb"]:not(:is(nav[aria-label], nav[aria-labelledby]) ol) {
  outline: var(--error-outline);
  --error-undiscoverable-breadcrumbs: 'It looks like you have provided breadcrumb navigation outside a labeled `<nav>` landmark';
}

(Note that this test finds both the omission of a <nav> element and the inclusion of a <nav> element but without a label.)

Another issue that came up was content not falling within a landmark (therefore escaping screen reader landmark navigation):

body :not(:is(header,nav,main,aside,footer)):not(:is(header,nav,main,aside,footer) *):not(.skip-link) {
  outline: var(--error-outline);
  --error-content-outside-landmark: 'You have some content that is not inside a landmark (header, nav, main, aside, or footer)';
}

(A more generalized version of this test would have to include the equivalent ARIA roles [role="banner"], [role="navigation"] etc.)

As a consultant, I’m often not permitted to access a client’s stack directly, to set up or extend accessibility-related tests. Where I am able to access the stack, there’s often a steep learning curve as to how everything fits together. I also have to go through various internal processes to contribute. It’s often the case there are multiple stacks/sites/platforms involved and they each have idiosyncratic approaches to testing. Some may not have Node-based testing in place yet at all. Some may do testing in a language I can barely read or write, like Java.

Since a test stylesheet is just CSS I can provide it independently. I don’t need to know the stack (or stacks) to which it can be applied. It’s an expedient way for clients to locate instances of specific bad patterns I’ve identified for them—and without having to “onboard” me to help them do so.

And it’s not just accessibility issues CSS tests can be used to find. What about HTML bloat?

:is(div > div > div > div > *) {
  outline: var(--warning-outline);
  --warning-divitis: 'There’s a whole lot of nesting going on here. Is it needed to achieve the layout? (it is not)';
}

Or general usability?

header nav:has(ul > ul) {
  outline: var(--warning-outline);
  --warning-nested-navigation: 'You appear to be using tiered/nested navigation in your header. This can be difficult to traverse. Index pages with tables of content are preferable.';
}

If you liked this post, please check out my videos about the web and maybe buy a T-shirt or hoodie or something.