I didn’t spend as much time in 2025 working on my gift registry, so all I really have is a login flow and profile management. I am building out a test suite that seems to do a pretty good job of validating the code works (I’ve only had a couple of bugs the automated tests didn’t catch, but did after a quick update, and more than a few that the tests did catch) – mostly because I’m just running the code when I test. It’s a server-side rendered app, using Go’s html/template package, so validating the output has been more difficult than reading JSON data like you would in a traditional web app. I tried Playwright, and that was really handy, but also slower than I wanted, so I abandoned it1, and wound up just writing a quick-and-dirty depth-first DOM search that does the validation that I really need, which wound up being a lot easier than I thought.
My first couple of tests used Go’s built-in HTML parser, but then I started looking for something easier to use in tests, which is what led me to Playwright. And it was a lot easier to use, and it worked, well. It just added time to tests that I didn’t really need to spend every time I wanted to build the code (as comforting as running the same tests against multiple major browsers was). Go’s built-in HTML parser is fast enough, and I had started using it a bit for a couple of early tests so when I dropped Playwright I just went back and fleshed that out.
The tests
Initially, my tests just involved a couple of checks:
- Does the element exist on the page?
- Is the element flagged as hidden?
For those early tests, I was looking at simple forms in the login flow and a health check page, so I just needed to confirm that elements were present, had content, and (in the case of field-level validation messages) marked as hidden2 unless I want them shown. Since I was already looping through the HTML to find the element in some simple top-level tests, I just turned that into a recursive, depth-first search so I could just loop through all the elements from root to find any element I’m looking for. From there, I just check the properties and attributes on the HTML node to look for “hidden” and then I’m done.
Another check – validating values
So far, the only thing that could have had a value was a health check, the contents of which I wasn’t going to enforce on a live run of the code, so I had just used the element exists check (which also checks that the HTML element has content) to confirm that the element was populated, which is good enough for building a quick update on my laptop.
As I added the user profile management, I needed to be able to look at the form and confirm it populated with data from the database. Since I already have the element, and confirmed it had a value, checking that was just a matter of matching the value I already knew existed against the expected value. Due to the way I had built the checks for the elements and their visibility, adding a value check was straightforward.
Entering the HTML validation loop
Let’s look at the code to see what I’m talking about:
// Holds the details needed to validate page contents
type ElementValidation struct {
Value string
Visible bool
}
// ValidatePage goes through the mapping of elements to validation details and
// confirms that the given HTML has the expected elements with the given
// properties.
func ValidatePage(page *html.Node, elements map[string]ElementValidation) error {
for id, validationInfo := range elements {
if pageElem, ok := CheckElement(*page, id); !ok {
return fmt.Errorf("could not find element %v on the page", id)
} else if elemVis := ElementVisible(pageElem); elemVis != validationInfo.Visible {
return fmt.Errorf("expected element %v to have visibility = %v, but it was %v", id, validationInfo.Visible, elemVis)
} else if validationInfo.Value != "" {
pageData := elementData(pageElem)
if validationInfo.Value != pageData {
return fmt.Errorf("expected element %v to have value = %v, but had %v",
id, validationInfo.Value, pageData)
}
}
}
return nil
}
All this code lives in a test package full of helper methods for automated testing. ElementValidation represents the struct holding my expected details with 2 fields – Visible (required, since otherwise Go will default it to false), and Value (optional) indicating what I want to see when I find the element. It’s worth noting that the initial pass on this was just tracking the visibility status, so I originally had a a map of element ID to whether it was visible on the page. I wrapped what I’m checking in a struct once I wanted to start checking values).
The map[string]ElementValidation parameter into the function is just a mapping of HTML element ID to the expected values of the HTML testing. So to look at an example from my tests:
func TestProfilePage(t *testing.T) {
testData := []struct {
userData test.UserData
managedData []test.UserData
elements map[string]test.ElementValidation
testName string
}{
{
elements: map[string]test.ElementValidation{
"profile-header-succ-disp-name": {
Value: "Root Named Profile",
Visible: true,
},
"profile-form-succ-disp-name": {Visible: true},
"first-name-succ-disp-name": {
Value: "Display",
Visible: true,
},
"last-name-succ-disp-name": {
Value: "Named",
Visible: true,
},
"display-name-succ-disp-name": {
Value: "Root",
Visible: true,
},
"email-succ-disp-name": {
Value: "displayName@localhost.com",
Visible: true,
},
"household-name-succ-disp-name": {
Value: "Disp",
Visible: true,
},
"profile-submit-succ-disp-name": {Visible: true},
"first-name-error-succ-disp-name": {Visible: false},
"household-error-succ-disp-name": {Visible: false},
"last-name-error-succ-disp-name": {Visible: false},
"profile-error-succ-disp-name": {Visible: false},
},
userData: test.UserData{
CreateHousehold: true,
DisplayName: "Root",
Email: "displayName@localhost.com",
ExternalID: "succ-disp-name",
FirstName: "Display",
HouseholdName: "Disp",
LastName: "Named",
},
testName: "Successful profile load with display name",
},
// More test cases defined here
}
}
userData is just the user information that will make up user who’s profile is being shown, but elements is where our focus is. ValidatePage() will look for #profile-header-succ-disp-name on the page, confirm that I have not marked it as hidden, and that the element either has a value attribute of “Root Named Profile” or (in this case) the text in the element is “Root Named Profile.” If any step in this process fails, return an error indicating what part of the validation failed. In the test itself, if the returned error has a value, fail the test:
doc, err := html.Parse(res.Body)
if err != nil {
t.Fatal("Error parsing response body!", err)
}
err = test.ValidatePage(doc, data.elements)
if err != nil {
t.Fatal(err)
}
Note that for the error message fields (like #first-name-error-succ-disp-name) I’m just checking visibility, namely that it’s marked as hidden. Since I shouldn’t see it on the page, there’s no need to check the value3.
The nice thing about this setup is that if I ever want to check for something else, I just need to update the ElementValidation type and ValidatePage() function to define and call the new check, and then just add the check to any element validations that need it, and that’s it. The tests themselves don’t need to be updated, just the test data.
Searching for the element
Most of the “work” happens in CheckElement():
func CheckElement(root html.Node, id string) (html.Node, bool) {
/*
If this element has the ID we're looking for, return true.
*/
if slices.Contains(root.Attr, html.Attribute{Key: "id", Val: id}) {
return root, true
}
/*
Do a depth-first search of all this element's children
to see if any of them match the ID we're looking for.
*/
for node := range root.Descendants() {
if childNode, ok := CheckElement(*node, id); ok {
return childNode, true
}
}
return html.Node{}, false
}
As it turns out, recursively searching the DOM tree for an element ID isn’t that hard. For the given element, see if it’s the 1 I’m looking for. If not, go through its children. If none of those are the element, use Go’s , ok pattern to indicate that no match was found. This way when I call CheckElement() I always get an HTMLNode back, no matter what happens.
Is the element (supposed to be) visible?
Once I’ve confirmed that I actually have an element, I check it’s “visibility”:
// Checks if the element has the hidden property or hidden class.
// Returns true if either is found
func ElementVisible(node html.Node) bool {
for _, attr := range node.Attr {
/*
An element is visible if it does not have the hidden property and does not
have the "hidden" class. We don't care about any other attribute
*/
switch attr.Key {
/* The hidden property means the element is not visible */
case "hidden":
return false
case "class":
/* The "hidden" class will set the element's display to none */
if strings.Contains(attr.Val, "hidden") {
return false
}
default:
continue
}
}
/* Assume the element is visible by default */
return true
}
As you may have guessed from the scare quotes around “visibility” – that’s not really what I’m checking. Instead, I’m checking for a property or class named “hidden.” Otherwise, I’m just declaring it “visible.” This is the type of thing Playwright (which has an element visible method) could probably do “properly” – returning false if a user couldn’t see the element on the page for any reason, not just because I added a “you can’t see me” flag. Remember, I just want something that’s going to give me confidence I’m setting flags on HTML snippets correctly every time I run make test, not do end-to-end UI tests.
Lastly, let’s check the content
Once I confirm the element exists and that I’m showing/hiding it correctly (or at last trying to), the last thing I need to do is confirm the HTML element has the right content. Now, checking the value is optional because on some pages, like the login page, the page first loads without any data. If I do have a value to look for, When I do need to extract a value, that logic looks like:
func elementData(pageElem html.Node) string {
/*
Prioritize the value attribute first. Then element body.
*/
for _, attr := range pageElem.Attr {
/*
Don't return on an empty value attribute value -
try element body next.
*/
if attr.Key == "value" && attr.Val != "" {
return attr.Val
}
}
if pageElem.FirstChild != nil {
return pageElem.FirstChild.Data
}
return ""
}
First, I check the attributes for the value attribute, since odds are I’m validating form data of some kind. If for any reason the element doesn’t have a value attribute, check for the element’s content, and use that. Worst-case scenario, return a “value” of just an empty string. From there, compare the text I’m looking for to what I found in the element.
Putting it all together
So, here’s everything needed to parse an HTML response and validate the contents:
// Holds the details needed to validate page contents
type ElementValidation struct {
Value string
Visible bool
}
func CheckElement(root html.Node, id string) (html.Node, bool) {
/*
If this element has the ID we're looking for, return true.
*/
if slices.Contains(root.Attr, html.Attribute{Key: "id", Val: id}) {
return root, true
}
/*
Do a depth-first search of all this element's children
to see if any of them match the ID we're looking for.
*/
for node := range root.Descendants() {
if childNode, ok := CheckElement(*node, id); ok {
return childNode, true
}
}
return html.Node{}, false
}
// Checks if the element has the hidden property or hidden class.
// Returns true if either is found
func ElementVisible(node html.Node) bool {
for _, attr := range node.Attr {
/*
An element is visible if it does not have the hidden property and does not
have the "hidden" class. We don't care about any other attribute
*/
switch attr.Key {
/* The hidden property means the element is not visible */
case "hidden":
return false
case "class":
/* The "hidden" class will set the element's display to none */
if strings.Contains(attr.Val, "hidden") {
return false
}
default:
continue
}
}
/* Assume the element is visible by default */
return true
}
// ValidatePage goes through the mapping of elements to validation details and
// confirms that the given HTML has the expected elements with the given
// properties.
func ValidatePage(page *html.Node, elements map[string]ElementValidation) error {
for id, validationInfo := range elements {
if pageElem, ok := CheckElement(*page, id); !ok {
return fmt.Errorf("could not find element %v on the page", id)
} else if elemVis := ElementVisible(pageElem); elemVis != validationInfo.Visible {
return fmt.Errorf("expected element %v to have visibility = %v, but it was %v", id, validationInfo.Visible, elemVis)
} else if validationInfo.Value != "" {
pageData := elementData(pageElem)
if validationInfo.Value != pageData {
return fmt.Errorf("expected element %v to have value = %v, but had %v",
id, validationInfo.Value, pageData)
}
}
}
return nil
}
func elementData(pageElem html.Node) string {
/*
Prioritize the value attribute first. Then element body.
*/
for _, attr := range pageElem.Attr {
/*
Don't return on an empty value attribute value -
try element body next.
*/
if attr.Key == "value" && attr.Val != "" {
return attr.Val
}
}
if pageElem.FirstChild != nil {
return pageElem.FirstChild.Data
}
return ""
}
When I decided to replace Playwright and go back to parsing the HTML using Go’s standard library, I thought it was going to be a massive pain. In the end, it really wasn’t that complicated. Checking the attributes feels a bit awkward, but not insurmountable. And the best part is, it’s now easy to to hand the HTML snippet my back-end is returning to a test method and confirm that I have the elements I expect, I’m hiding the elements I don’t want to see, not hiding the elements I do, and for the elements that will have content, they’re coming back with the correct data.
So what’s next? I deleted a TODO comment with a note to potentially add a check to confirm an element isn’t on the page – but that would just be a new field in the ElementValidation struct and a fresh if-statement after calling CheckElement(). Nice and easy.
There’s something to be said for trying to see what it would take for writing something using the standard library before reaching for 3rd-party code. Not only did removing Playwright speed up my tests, but it wasn’t even that hard to get the level of coverage I wanted for that stage of the development process just using the standard library.
My Playwright “replacement” isn’t anywhere near as good as the original, but it’s a nice and simple take that does just the little thing I need. It’s also a nice confidence boost to know that I can build useful utilities in Go as I work. This is far from good enough to be put out as a package, but it is useful enough to be copied-and pasted into any other server-side rendered web apps I write in Go in the future.
- I may bring it back later on for a full end-to-end tests at some point that isn’t part of the normal
make testcommand, but that’s a ways out if ever. ↩︎ - By “marked as hidden”, I mean the HTML element has the “hidden” attribute or property. ↩︎
- In general I’m trying to make sure I’m not testing values of things like error messages in the UI these days and focus more on whether it’s there or not. From a “does the app function” standpoint, that’s what matters. There’s still place for an automated test checking the sequence of characters in those kinds of fields, but that place ain’t my laptop – it’s a CI stage much closer to “put this executable in production” ↩︎