<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jeff Thoensen</title>
    <description>The latest articles on DEV Community by Jeff Thoensen (@jeffthoensen).</description>
    <link>https://dev.to/jeffthoensen</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3276950%2F96ee1be3-4a19-430a-851d-2fbfcbe774fb.jpg</url>
      <title>DEV Community: Jeff Thoensen</title>
      <link>https://dev.to/jeffthoensen</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jeffthoensen"/>
    <language>en</language>
    <item>
      <title>When the Mock Is Right and the API Isn't Anymore</title>
      <dc:creator>Jeff Thoensen</dc:creator>
      <pubDate>Sat, 25 Jul 2026 17:10:18 +0000</pubDate>
      <link>https://dev.to/jeffthoensen/when-the-mock-is-right-and-the-api-isnt-anymore-33ai</link>
      <guid>https://dev.to/jeffthoensen/when-the-mock-is-right-and-the-api-isnt-anymore-33ai</guid>
      <description>&lt;p&gt;A test suite built against a mock API passed every run for months. The real endpoint's response contract had changed in that time, but the mock hadn't, and nothing caught it because the tests only ever talked to the mock.&lt;/p&gt;

&lt;p&gt;The mock returned a users array with name and email on each object. That was accurate when I built the mock, matching the real API's response at the time. Sometime after that, the backend team added a status field and split name into firstName and lastName. The integration hadn't been exercised against the real API since that change went out, so my test suite kept asserting against a version of the response that no longer existed anywhere but in my mock file.&lt;/p&gt;

&lt;p&gt;The suite caught nothing, because nothing about it was ever wrong. It was internally consistent: the mock returned the old shape, and the tests checked for the old shape. It stayed green the whole time, testing against a contract that had already changed underneath it.&lt;/p&gt;

&lt;p&gt;I added a separate, small set of contract tests that hit the real API directly, on a schedule instead of every commit, and assert that the fields and types the application depends on are still present, rather than asserting on the specific data returned. That suite is slower and talks to a real backend, which is exactly why it isn't run on every push. Its only job is catching the moment a mock and reality drift apart, which the fast mocked suite structurally cannot do.&lt;/p&gt;

&lt;p&gt;The mock is still worth having. It's fast, and it isolates frontend logic from backend uptime, which is most of why teams reach for mocking a CRM integration in the first place. What changed is treating the mock as something that needs its own maintenance, tied to the real contract, instead of something written once during initial development and trusted indefinitely afterward.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>api</category>
      <category>mocking</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Test That Only Failed in CI, Never Locally</title>
      <dc:creator>Jeff Thoensen</dc:creator>
      <pubDate>Sat, 25 Jul 2026 16:53:48 +0000</pubDate>
      <link>https://dev.to/jeffthoensen/the-test-that-only-failed-in-ci-never-locally-4fj3</link>
      <guid>https://dev.to/jeffthoensen/the-test-that-only-failed-in-ci-never-locally-4fj3</guid>
      <description>&lt;p&gt;A test checking that a scheduled reminder appeared on the right day passed every time I ran it on my machine and failed every time it ran in CI, with nothing about the code or the test itself different between the two runs. The only thing that was different was the timezone the two machines were running in.&lt;/p&gt;

&lt;p&gt;My machine was set to Eastern time, during the part of the year when Eastern runs four hours behind UTC. The CI runner ran in UTC. The test scheduled a reminder for 11 PM on a given day and checked that it showed up under that same calendar date. At 11 PM Eastern, the date comparison ran fine, because the reminder and the check were both being read in the same local time. Four hours ahead in UTC, 11 PM Eastern is already 3 AM the next day, so the reminder that should have shown up "today" was landing on tomorrow instead, and the test correctly failed against a real bug in how the date was being compared.&lt;/p&gt;

&lt;p&gt;The bug lived in the feature: the reminder was meant to fire at one specific scheduled instant, not sometime across the user's local calendar day, so comparing it against "today" only makes sense once both sides are read in the same timezone. Comparing a reminder's timestamp against today without ever converting both to a common timezone first happens to work by accident whenever the two machines involved share the same offset from UTC, and stops working the moment they don't.&lt;/p&gt;

&lt;p&gt;I fixed the comparison by converting both sides to UTC before comparing, since the reminder represents one fixed instant rather than a day tied to whoever happens to be looking at it. If the feature had actually meant "today on the user's own calendar," converting to UTC would have been the wrong fix, since two users in different timezones can disagree about what day a given instant falls on, and the right move there would have been converting both sides into the user's local timezone instead. Getting that distinction right mattered more than which timezone I ended up picking.&lt;/p&gt;

&lt;p&gt;I also set CI to run in a timezone deliberately different from wherever tests get run manually, so a mismatch like this fails immediately instead of waiting for whichever engineer's laptop happens to be in a different zone than the pipeline.&lt;/p&gt;

&lt;p&gt;The habit worth keeping from this: any test involving dates is also, whether anyone intended it or not, a test of timezone handling. Code that only works because two clocks happen to agree is running on coincidence, and the only way to find that out before a user does is making sure your test environment doesn't coincidentally match your dev machine's timezone.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>playwright</category>
      <category>ci</category>
    </item>
    <item>
      <title>Where Exploratory Testing Earns Its Keep</title>
      <dc:creator>Jeff Thoensen</dc:creator>
      <pubDate>Sat, 18 Jul 2026 17:25:02 +0000</pubDate>
      <link>https://dev.to/jeffthoensen/where-exploratory-testing-earns-its-keep-1id6</link>
      <guid>https://dev.to/jeffthoensen/where-exploratory-testing-earns-its-keep-1id6</guid>
      <description>&lt;p&gt;Happy paths should be automated, and that part isn't up for debate. A suite that runs the same core flows on every commit is what actually keeps a release stable, and there's no version of "we'll just check it manually before every deploy" that scales past a small team. Once that's in place, the question is what's actually left for a person to do once the obvious stuff already runs itself.&lt;/p&gt;

&lt;p&gt;I keep a bulk-edit feature as the example I come back to. The automated suite covers selecting rows, editing a field, saving, and confirming the change. Green on every run, and it should stay that way. None of that is where I spend exploratory time, since it's already covered and covered well.&lt;/p&gt;

&lt;p&gt;Where I actually spend time is the stuff nobody wrote an assertion for, because nobody thought to yet. I select a few hundred rows instead of a few, since that's closer to how someone treats a bulk-edit feature once they trust it. The save button spun and never resolved, with nothing in the UI to indicate anything had gone wrong. The backend had a request size limit that silently dropped the request past a few dozen rows.&lt;/p&gt;

&lt;p&gt;That gap isn't a hole in the automated suite so much as the shape of what automation structurally can't reach on its own. A human has to imagine the scenario before anyone can write a test for it, and a scenario like selecting far more rows than the test data ever used isn't something you arrive at by making the existing suite more thorough. You arrive at it by using the feature the way someone under real conditions would, which usually means pushing past whatever amount the test data was written to cover.&lt;/p&gt;

&lt;p&gt;The other place a person earns their keep is judgment calls automation can't make. A test can assert that a field shows an error message. It can't tell you the message is confusing, or that the error appears in a spot nobody would look, or that the flow technically works but takes four more clicks than it should. Those aren't failures a script can detect, since there's no clear pass or fail condition to assert on. Someone has to actually use the thing and notice.&lt;/p&gt;

&lt;p&gt;I still treat the automated suite as the floor, not something exploratory testing is meant to replace or double-check. The floor is what keeps regressions from shipping. What a session is actually for is the edges nobody imagined yet, and the parts of the experience only a person can judge.&lt;/p&gt;

&lt;p&gt;Jeff Thoensen is a Context-Driven QA Engineer focused on automation, API testing, and exploratory testing. Find more at jeffthoensen.com&lt;/p&gt;

</description>
      <category>automation</category>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
      <category>testing</category>
    </item>
    <item>
      <title>My AI-Generated Tests Kept Passing for the Wrong Reason</title>
      <dc:creator>Jeff Thoensen</dc:creator>
      <pubDate>Tue, 14 Jul 2026 20:46:16 +0000</pubDate>
      <link>https://dev.to/jeffthoensen/my-ai-generated-tests-kept-passing-for-the-wrong-reason-26ml</link>
      <guid>https://dev.to/jeffthoensen/my-ai-generated-tests-kept-passing-for-the-wrong-reason-26ml</guid>
      <description>&lt;p&gt;I started using AI to generate Playwright test scaffolding a few months ago. Feed it a user flow, whether it's a login form or a multi-step checkout, and you get back a working spec in under a minute that runs and mostly passes on the first try. &lt;/p&gt;

&lt;p&gt;I asked a model to write a test for a password reset flow, and it built a spec that filled in the email field, clicked submit, and checked for a success message on screen. That passed every time I ran it, but it never checked whether an email actually went out, whether the link inside it worked, or whether that same link still worked after being used once. The page reported success no matter what the backend did, so the test just checked the page's opinion of itself.&lt;/p&gt;

&lt;p&gt;Before I touched it, the spec looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;testUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Send reset link&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Check your email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three lines passed, which is exactly the problem: none of them checked anything that actually mattered. Once I rewrote the assertions, it looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;testUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Send reset link&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Check your email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resetEmail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getLatestEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;testUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extractResetToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resetEmail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/reset?token=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;New password&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NewPassword123!&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Reset password&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/reset?token=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;This link has expired&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The expired-token check at the end is what caught a real bug: the first version of the reset endpoint let a token be reused as many times as someone wanted, with no expiration after it was used once.&lt;/p&gt;

&lt;p&gt;None of this makes AI bad at writing tests. It's good at the boilerplate, at generating locators and shaping a spec file enough that I'm editing instead of starting from nothing, but it can't decide which outcome the business actually depends on, because that context doesn't live in the DOM. A password reset link that never expires looks identical on screen to one that works the way it should, and the same gap shows up anywhere a UI can report success independent of what the backend actually did, checkout confirmations and invite emails included.&lt;/p&gt;

&lt;p&gt;I still generate the first draft of most new specs with a model, but I rewrite the assertions myself, against what the feature is actually supposed to guarantee.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Jeff Thoensen is a Context-Driven QA Engineer focused on automation, API testing, and exploratory testing. Find more at &lt;a href="https://jeffthoensen.com" rel="noopener noreferrer"&gt;jeffthoensen.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>qa</category>
      <category>testing</category>
    </item>
    <item>
      <title>Testing a LiveView App with Playwright: Fixing Navigation Timeouts</title>
      <dc:creator>Jeff Thoensen</dc:creator>
      <pubDate>Wed, 27 May 2026 22:07:52 +0000</pubDate>
      <link>https://dev.to/jeffthoensen/testing-a-liveview-app-with-playwright-fixing-navigation-timeouts-5b2f</link>
      <guid>https://dev.to/jeffthoensen/testing-a-liveview-app-with-playwright-fixing-navigation-timeouts-5b2f</guid>
      <description>&lt;p&gt;I was building a Playwright suite against a Phoenix LiveView app for the first time. Tests ran fine in isolation. Overnight, the full suite timed out across the board. Every failure was in a navigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Playwright Waits For by Default
&lt;/h2&gt;

&lt;p&gt;When you call &lt;code&gt;page.goto()&lt;/code&gt; or trigger a navigation through a click, Playwright waits for the &lt;code&gt;load&lt;/code&gt; event before moving on. That assumes a traditional request/response cycle: the browser makes a request, the server returns a full HTML document, the browser fires &lt;code&gt;load&lt;/code&gt; when everything is done.&lt;/p&gt;

&lt;p&gt;LiveView doesn't do that. Navigation happens over a persistent WebSocket connection. The URL changes, the page updates, but there's no new document and no &lt;code&gt;load&lt;/code&gt; event in the way Playwright expects. So Playwright waits, hits the timeout, and the test fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: waitUntil commit
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;waitUntil&lt;/code&gt; option controls what signal Playwright waits for before continuing. Setting it to &lt;code&gt;'commit'&lt;/code&gt; tells Playwright to return as soon as the server has responded and the navigation is committed, without waiting for a full page load cycle that isn't coming.&lt;/p&gt;

&lt;p&gt;The change applies anywhere a navigation happens — &lt;code&gt;goto&lt;/code&gt; and &lt;code&gt;waitForURL&lt;/code&gt; both take the option:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// goto&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;commit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// waitForURL&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;library&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;commit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was a 12-commit PR touching every navigation across the suite. Not a complicated change per file, but it had to be consistent. One nav without &lt;code&gt;waitUntil: 'commit'&lt;/code&gt; is enough to hang the whole overnight run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Second Step: waitForLiveView
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;waitUntil: 'commit'&lt;/code&gt; gets you past the hang, but it returns early. LiveView still needs to establish its WebSocket connection and mount the view. Acting on the page before that happens produces flaky results.&lt;/p&gt;

&lt;p&gt;The pattern is to follow every navigation with a &lt;code&gt;waitForLiveView&lt;/code&gt; helper that waits for the socket to connect before returning control to the test. It was already in the suite — the missing piece was &lt;code&gt;waitUntil: 'commit'&lt;/code&gt; on the line before it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;commit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;waitForLiveView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// or after a click-triggered navigation&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;library&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;commit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;waitForLiveView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is one exception worth noting. When the navigation is a plain href link transition rather than a LiveView-driven one, &lt;code&gt;waitForURL&lt;/code&gt; governs the transition on its own and &lt;code&gt;waitForLiveView&lt;/code&gt; isn't needed. A comment in the diff flags exactly that case: &lt;code&gt;// href link — waitForURL governs this transition, not waitForLiveView&lt;/code&gt;. Knowing which navigations go through LiveView and which don't matters when you're deciding where to apply the full pattern.&lt;/p&gt;

&lt;p&gt;The actual helper in this suite does more than a basic selector wait. The environment has a brief WebSocket drop around the nightly deploy window, so there's a retry path built in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitForLiveView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;retryWithReload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;retryWithReload&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;retryWithReload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.phx-connected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;waitFor&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.phx-connected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;waitFor&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;commit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.phx-connected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;waitFor&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default path just waits for &lt;code&gt;.phx-connected&lt;/code&gt;. Pass &lt;code&gt;retryWithReload: true&lt;/code&gt; when the page is in a clean navigable state and a reload is safe — if the socket connection times out, the helper reloads and tries once more. Avoid it inside open modals, filled forms, or any state a reload would destroy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Broader Point
&lt;/h2&gt;

&lt;p&gt;Playwright's defaults are reasonable for the apps they were designed around. When you add a new framework to your suite, those defaults are the first thing worth checking. The assumption baked into &lt;code&gt;waitUntil: 'load'&lt;/code&gt; is that navigation means a new document. LiveView changes that contract, and the suite breaks until you adjust for it.&lt;/p&gt;

&lt;p&gt;The tool doesn't know what kind of app it's running against. You do. The configuration has to reflect that. More on this kind of thing at &lt;a href="https://jeffthoensen.com" rel="noopener noreferrer"&gt;jeffthoensen.com&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Jeff Thoensen is a Context-Driven QA Engineer focused on automation, API testing, and exploratory testing. Find more at &lt;a href="https://jeffthoensen.com" rel="noopener noreferrer"&gt;jeffthoensen.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>testing</category>
      <category>elixir</category>
      <category>qa</category>
    </item>
    <item>
      <title>Don't Automate Everything</title>
      <dc:creator>Jeff Thoensen</dc:creator>
      <pubDate>Sat, 23 Aug 2025 16:45:57 +0000</pubDate>
      <link>https://dev.to/jeffthoensen/dont-automate-everything-1889</link>
      <guid>https://dev.to/jeffthoensen/dont-automate-everything-1889</guid>
      <description>&lt;p&gt;I went heavy on Playwright early on. Wrote tests for every button and every form that I could. It looked solid until a small UI change broke a dozen of them because I’d used getByText in bad spots. The suite became useless for a while, and we were shipping code without it (so what was the point of all that work?).&lt;/p&gt;

&lt;p&gt;Now I focus on automating only the things I don’t want to do manually: setup flows, core user paths, data checks. If I can’t explain why a test exists, I don’t write it. I also check UI mocks early so I’m not surprised when labels change or elements move. It saves time and keeps the suite stable.&lt;/p&gt;

&lt;p&gt;I keep my suites small on purpose. Five reliable tests are better than fifty flaky ones, and flaky tests destroy trust in QA fast. I also treat tests like real code. Reviews, refactors, small commits. If you skip that part, the suite turns into a mess quickly.&lt;/p&gt;

&lt;p&gt;I use Playwright every day, but I’m not automating to hit numbers. It’s there to save time and keep focus on users, not on constantly broken tests.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.jeffthoensen.com" rel="noopener noreferrer"&gt;jeffthoensen.com&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;a href="https://github.com/jeff-thoensen" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;a href="https://www.linkedin.com/in/jeffthoensen/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;a href="https://jeff-thoensen.medium.com/" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;a href="https://jeffthoensen.substack.com/" rel="noopener noreferrer"&gt;Substack&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;a href="https://www.indiehackers.com/jeffthoensen" rel="noopener noreferrer"&gt;Indie Hackers&lt;/a&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>playwright</category>
    </item>
    <item>
      <title>Hello QA!</title>
      <dc:creator>Jeff Thoensen</dc:creator>
      <pubDate>Fri, 27 Jun 2025 14:23:46 +0000</pubDate>
      <link>https://dev.to/jeffthoensen/hi-im-jeff-im-in-qa-1m30</link>
      <guid>https://dev.to/jeffthoensen/hi-im-jeff-im-in-qa-1m30</guid>
      <description>&lt;p&gt;I’ve been in QA for about 10 years, with a background in customer support that gave me a real sense of how bugs hit users.&lt;/p&gt;

&lt;p&gt;I care about testing as a way to reduce risk and protect the user experience, not just checking boxes. These days I do a mix of exploratory testing and Playwright automation, but the goal’s the same: find the stuff that actually matters.&lt;/p&gt;

&lt;p&gt;If you're into testing that goes beyond coverage reports, we’re probably on the same page.&lt;/p&gt;

</description>
      <category>qa</category>
      <category>testing</category>
      <category>softwaretesting</category>
      <category>qualityassurance</category>
    </item>
  </channel>
</rss>
