
I Wanted Course Authors to Write Tests. I Built a Language.
How a simple request to let course authors write assertions turned into building a domain-specific language — and what that taught me about every API I use.
On this page
I didn’t set out to build a DSL.
I set out to solve a much simpler problem: course authors on my learning platform needed to write assertions. Simple stuff — check that an h1 exists, check that a button is visible, check that a form submits correctly.
I thought I’d wrap a testing framework and call it done. Two days later I was writing a parser.
That gap — between “this should be simple” and “I’m now maintaining a language” — is worth explaining.
What a DSL Actually Is
Before getting into how I built one — it’s worth being clear on what a DSL actually is, because the term gets thrown around without much explanation.
DSL stands for Domain-Specific Language. It’s a language built for one job, rather than general-purpose programming.
You’ve almost certainly used one:
- SQL — only for querying data
- CSS — only for styling
- Regex — only for pattern matching
None of these are full programming languages. You can’t build an API in CSS. But within their domain, they’re incredibly expressive — because they speak the language of the problem, not the language of the computer.
That’s the idea. And it’s exactly what I ended up needing.
The Problem
I needed authors to define what correct looks like. The obvious solution was a testing framework:
cy.get("h1")
.should("have.length", 1)
.and("contain", "Hello World");Technically, this works. But my users aren’t developers. That syntax is unfamiliar, and every test becomes a programming problem before it’s even a content problem.
That’s when it clicked: this isn’t a testing problem. It’s a language problem.
Why Simplifying the Code Didn’t Help
My first instinct was to wrap Cypress . Hide the complexity behind friendlier functions. Less chaining, cleaner names, fewer concepts to learn.
I spent a day on it. Then I looked at what I’d built.
It still looked like code.
That’s when I realised the mistake. I’d been thinking about this as a code problem — how do I make this API simpler? But the people who’d be writing these tests didn’t think in APIs. They didn’t think in functions or chains or return values. They thought in instructions.
Select this. Check that. Click here. Wait for this to appear.
I hadn’t removed complexity. I’d just reshaped it into something that still felt foreign. So I stopped asking how do I simplify JavaScript? and started asking what if this wasn’t JavaScript at all?
What I Actually Wanted Authors to Write
SELECT "h1"
ASSERT "have.length" 1
ASSERT "contains" "Hello World"
CLICK ".submit-btn"
WAIT ".success-message"
ASSERT "visible"No imports. No semicolons. No framework knowledge.
It reads like instructions — because it is. I didn’t simplify code. I removed the idea of code entirely.
The Three Layers Behind It
A DSL sounds complex. It’s really just three things working together. And the reason they’re three separate layers — not one — is the same reason you don’t mix your database queries with your UI code. Each layer has one job. That separation is what makes the whole thing maintainable.
1. Syntax — what the author writes.
This is the only layer users ever see. Every decision matters: SELECT instead of get, quotes for clarity, one command per line. It’s not just syntax — it’s a language designed around a specific person’s mental model.
The constraint is intentional. A limited syntax is harder to misuse. If the language only lets you do five things, you can’t accidentally do something wrong in six different ways.
2. The compiler — translation, not execution.
This is where most people’s mental model breaks down. The compiler doesn’t run your tests. It just translates human-readable syntax into structured data the runner can consume.
So this:
ASSERT "have.length" 1
ASSERT "visible"Becomes this:
[
{ name: "should", args: ["have.length", 1] },
{ name: "should", args: ["be.visible", true] }
]And internally, each assertion type has a known translation:
case "count":
return [{ name: "should", args: ["have.length", assertion.equals] }];
case "is.visible":
return [{ name: "should", args: ["be.visible", true] }];Why keep this separate from the runner? Because it means you can test the compiler in complete isolation — no DOM, no browser, no setup. You give it a string, you get back a list of actions. If the output is wrong, you know exactly where the bug is.
3. The runner — where things actually happen.
The runner takes that list of actions and executes them against a real DOM. It walks through them in order, finds elements, runs assertions, and tracks what passed and what failed:
if (action.name === "get") {
selectedElements = [...document.querySelectorAll(action.args[0])];
}
if (action.name === "should") {
const passed = assertionRegistry[action.args[0]](selectedElements, ...);
this.recordAssertionResult(passed);
}The runner has no idea what the original syntax looked like. It only knows about actions. That’s the point — each layer only knows what it needs to.
The Part I Didn’t Expect to Configure
Here’s what the DSL looks like without any editor support:
ASSR "have.length" 1No feedback. No error. The author stares at it, wondering why nothing worked. They check the exercise. They check the student’s code. They never think to check the spelling of their own command.
That’s the failure mode. And it’s why the editor experience isn’t optional.
I didn’t build an editor from scratch — I used CodeMirror and configured it for the DSL. But that configuration is where most of the user experience actually lives:
- A linter that underlines unknown commands inline the moment you finish typing, with a red dot in the gutter on the offending line
- Context-aware autocomplete — typing
ASSERTsurfaces assertion options likehave.lengthandvisible, not top-level commands - Real-time JSON output in a panel alongside the editor, so authors can see exactly what the compiler is producing as they type
The result is that the same typo is caught immediately — before the author ever runs a test, before they go looking in the wrong place. The fix takes seconds rather than minutes of confusion.
The language went from something that could work to something that feels guided. That shift is entirely down to the tooling around it, not the syntax itself.
→ Try the Test Logic Editor on CodePen
The Trade-offs
Building a DSL isn’t free. And it’s worth being honest about what that actually costs — not just in time, but in ongoing responsibility.
You become the maintainer of a language. Every error message is yours. Every edge case the parser doesn’t handle is yours. Every time an author writes something slightly wrong and gets no useful feedback — that’s a failure you own.
For a small internal tool, that’s probably not worth it. A few wrapper functions and a README gets you most of the way there with a fraction of the overhead.
But for a platform where non-developers need to express technical behaviour repeatedly — where the alternative is asking course authors to learn Cypress — the structure pays for itself quickly. The investment you make upfront is cheaper than the support burden you’d carry without it.
The question to ask isn’t “is this complex enough for a DSL?” It’s “who is going to be writing this, and what happens when they get it wrong?” If the answer is non-developers, and the answer to the second part is “they’ll have no idea why” — you probably need more than a simplified API.
What Building One Actually Changes
Once you build a DSL, you stop seeing languages as fixed things. You start seeing them as decisions.
Tailwind is a constraint system. Jest’s expect is sentence structure. GitHub Actions YAML is a rigid language with genuinely poor UX. They’re all just languages shaped around the person using them — some more deliberately than others.
Before building this, I used those tools without thinking much about the choices behind them. After, I started noticing what they allow, what they prevent, and what they make easy versus painful.
Final Takeaway
I needed course authors to write test logic without writing code.
The answer wasn’t a simpler framework. It was a different kind of language — one that spoke their vocabulary, not JavaScript’s.
But here’s what I didn’t expect: the moment you build a DSL, you stop being a passive user of everyone else’s. You start reading APIs differently. Tailwind’s class names, Jest’s expect, GitHub Actions YAML — they stop looking like tools and start looking like decisions. Tradeoffs. Someone’s considered opinion about what their users should and shouldn’t be able to express.
Every API is a language. Most people just never have to think about it.
Building one — even a small one — means you do. And once you see it, you can’t un-see it.
Originally published on Medium .
2026 © Jordan O’Hara

