<?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: Tayguara Reis</title>
    <description>The latest articles on DEV Community by Tayguara Reis (@tayguara).</description>
    <link>https://dev.to/tayguara</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%2F4154832%2F8a2a1961-a75e-4fc2-925c-e906f46a9fb2.jpg</url>
      <title>DEV Community: Tayguara Reis</title>
      <link>https://dev.to/tayguara</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tayguara"/>
    <language>en</language>
    <item>
      <title>BDD with Playwright: Gherkin scenarios that business people can actually read</title>
      <dc:creator>Tayguara Reis</dc:creator>
      <pubDate>Thu, 01 Oct 2026 13:25:21 +0000</pubDate>
      <link>https://dev.to/tayguara/bdd-with-playwright-gherkin-scenarios-that-business-people-can-actually-read-om9</link>
      <guid>https://dev.to/tayguara/bdd-with-playwright-gherkin-scenarios-that-business-people-can-actually-read-om9</guid>
      <description>&lt;p&gt;I have written and maintained end-to-end suites with Cucumber on top of Playwright, using the cucumber-js runner. It works, but two risks show up in every Gherkin suite. Either the feature files turn into click-scripts with a Given/When/Then prefix ("When I click the button with id continue"), so nobody outside engineering reads them, or the step layer grows into a second framework with its own world object, its own retries and its own reporting, rebuilt next to a test runner that already does all of that.&lt;/p&gt;

&lt;p&gt;Gherkin is worth it only when a product owner can read a scenario and say "yes, that is the rule." Everything below comes from a small public repository I maintain, &lt;a href="https://github.com/tayguara/playwright-qa-showcase" rel="noopener noreferrer"&gt;playwright-qa-showcase&lt;/a&gt;, which runs 10 Gherkin scenarios (16 tests once the outlines expand) against the SauceDemo store with Playwright 1.63 and playwright-bdd 9.2.1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why playwright-bdd instead of cucumber-js
&lt;/h2&gt;

&lt;p&gt;playwright-bdd does not run your scenarios in a separate runner. &lt;code&gt;bddgen&lt;/code&gt; compiles the &lt;code&gt;.feature&lt;/code&gt; files into ordinary Playwright spec files, and the Playwright test runner executes them. That one decision gives you everything the runner already does well: fixtures, full parallelism, traces, the HTML report, retries, sharding and blob-report merging in CI.&lt;/p&gt;

&lt;p&gt;The whole BDD setup is a few lines in &lt;code&gt;playwright.config.ts&lt;/code&gt;:&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;// BDD is used for the UI project only. `bddgen` turns the .feature files into Playwright specs.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;uiTestDir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineBddConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;features&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;features/**/*.feature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;features/steps/**/*.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;src/fixtures/ui.fixtures.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;outputDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.features-gen/ui&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ui&lt;/code&gt; project then points &lt;code&gt;testDir&lt;/code&gt; at &lt;code&gt;uiTestDir&lt;/code&gt;, and the same config keeps &lt;code&gt;trace: 'on-first-retry'&lt;/code&gt; in CI and &lt;code&gt;'retain-on-failure'&lt;/code&gt; locally. Gherkin tags become native Playwright tags, so &lt;code&gt;npx playwright test --grep @smoke&lt;/code&gt; selects the two smoke scenarios with no extra tooling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where BDD pays off, and where it does not
&lt;/h2&gt;

&lt;p&gt;The repository has four Playwright projects, and only one of them uses Gherkin:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;UI business flows (login, inventory, checkout):&lt;/strong&gt; Gherkin. A readable scenario is useful to non-engineers, and the steps are reused across features.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API tests:&lt;/strong&gt; plain Playwright. Their value is in assertions on status codes, headers and payload contracts. "Then the response status is 200" adds a translation layer without adding clarity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accessibility checks:&lt;/strong&gt; plain Playwright. The interesting output is a list of axe rule ids per page, not a sentence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unit tests for test helpers:&lt;/strong&gt; plain Playwright, no browser.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the first judgment call I make on any engagement. BDD is a communication tool. Where there is no one to communicate with outside engineering, it is overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing steps in business language
&lt;/h2&gt;

&lt;p&gt;Here is the core checkout scenario:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gherkin"&gt;&lt;code&gt;&lt;span class="nt"&gt;@smoke&lt;/span&gt;
&lt;span class="kn"&gt;Scenario&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;A &lt;/span&gt;shopper completes a purchase end to end
  &lt;span class="err"&gt;Given the cart contains the following products&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nv"&gt;product&lt;/span&gt;               &lt;span class="p"&gt;|&lt;/span&gt;
    &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Sauce&lt;/span&gt; &lt;span class="n"&gt;Labs&lt;/span&gt; &lt;span class="n"&gt;Backpack&lt;/span&gt;   &lt;span class="p"&gt;|&lt;/span&gt;
    &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Sauce&lt;/span&gt; &lt;span class="n"&gt;Labs&lt;/span&gt; &lt;span class="n"&gt;Bike&lt;/span&gt; &lt;span class="n"&gt;Light&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;
  &lt;span class="nf"&gt;When &lt;/span&gt;I open the cart
  &lt;span class="nf"&gt;And &lt;/span&gt;I start the checkout
  &lt;span class="nf"&gt;And &lt;/span&gt;I submit valid shipping information
  &lt;span class="nf"&gt;Then &lt;/span&gt;the order overview lists the same products
  &lt;span class="nf"&gt;And &lt;/span&gt;the item total equals the sum of the item prices
  &lt;span class="nf"&gt;And &lt;/span&gt;the order total equals the item total plus tax
  &lt;span class="nf"&gt;When &lt;/span&gt;I finish the order
  &lt;span class="nf"&gt;Then &lt;/span&gt;I see the order confirmation &lt;span class="s"&gt;"Thank you for your order!"&lt;/span&gt;
  &lt;span class="nf"&gt;And &lt;/span&gt;the cart badge is not shown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are no selectors, no field names and no test data that does not matter to the rule. "I submit valid shipping information" is declarative: the step knows what valid means (a synthetic &lt;code&gt;Ada / Lovelace / 12345&lt;/code&gt; record in &lt;code&gt;src/data/checkoutData.ts&lt;/code&gt;). The imperative version, three "When I fill ..." lines, would tell a reader nothing new and break the scenario every time the form changes.&lt;/p&gt;

&lt;p&gt;When the variation &lt;em&gt;is&lt;/em&gt; the rule, I spell it out, and a Scenario Outline keeps it to one scenario:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gherkin"&gt;&lt;code&gt;&lt;span class="kn"&gt;Scenario Outline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="err"&gt;Required shipping fields are validated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="err"&gt;&amp;lt;case&amp;gt;&lt;/span&gt;
  &lt;span class="err"&gt;...&lt;/span&gt;
  &lt;span class="nf"&gt;When &lt;/span&gt;I submit the shipping form with first name &lt;span class="s"&gt;"&amp;lt;first_name&amp;gt;"&lt;/span&gt;, last name &lt;span class="s"&gt;"&amp;lt;last_name&amp;gt;"&lt;/span&gt; and postal code &lt;span class="s"&gt;"&amp;lt;postal_code&amp;gt;"&lt;/span&gt;
  &lt;span class="nf"&gt;Then &lt;/span&gt;I see the checkout error &lt;span class="s"&gt;"&amp;lt;error&amp;gt;"&lt;/span&gt;
  &lt;span class="nf"&gt;And &lt;/span&gt;I am still on the shipping information step

  &lt;span class="nn"&gt;Examples&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nv"&gt;case&lt;/span&gt;                &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nv"&gt;first_name&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nv"&gt;last_name&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nv"&gt;postal_code&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nv"&gt;error&lt;/span&gt;                          &lt;span class="p"&gt;|&lt;/span&gt;
    &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;|&lt;/span&gt;            &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Lovelace&lt;/span&gt;  &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;12345&lt;/span&gt;       &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Error:&lt;/span&gt; &lt;span class="n"&gt;First&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="n"&gt;is&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt;  &lt;span class="p"&gt;|&lt;/span&gt;
    &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;   &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Ada&lt;/span&gt;        &lt;span class="p"&gt;|&lt;/span&gt;           &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;12345&lt;/span&gt;       &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Error:&lt;/span&gt; &lt;span class="n"&gt;Last&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="n"&gt;is&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt;   &lt;span class="p"&gt;|&lt;/span&gt;
    &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="n"&gt;postal&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Ada&lt;/span&gt;        &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Lovelace&lt;/span&gt;  &lt;span class="p"&gt;|&lt;/span&gt;             &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Error:&lt;/span&gt; &lt;span class="n"&gt;Postal&lt;/span&gt; &lt;span class="n"&gt;Code&lt;/span&gt; &lt;span class="n"&gt;is&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two smaller choices matter as much as the wording. In the sorting outline, "Name (A to Z)" is deliberately left out of the examples, with a comment explaining why: it is the default order, so it would pass even if the sort control did nothing. And the login outline says "a  password" (&lt;code&gt;valid&lt;/code&gt;, &lt;code&gt;wrong&lt;/code&gt;, &lt;code&gt;empty&lt;/code&gt;) instead of putting the password in the feature file. The step maps the kind to a value, and the real password stays in &lt;code&gt;config/env.ts&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixtures and page objects, injected into steps
&lt;/h2&gt;

&lt;p&gt;Steps never construct page objects. They receive them as Playwright fixtures, which &lt;code&gt;createBdd&lt;/code&gt; binds to &lt;code&gt;Given&lt;/code&gt;/&lt;code&gt;When&lt;/code&gt;/&lt;code&gt;Then&lt;/code&gt;:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;extend&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;UiFixtures&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;inventoryPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &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;use&lt;/span&gt;&lt;span class="p"&gt;)&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;await&lt;/span&gt; &lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InventoryPage&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="na"&gt;checkoutOverviewPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &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;use&lt;/span&gt;&lt;span class="p"&gt;)&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;await&lt;/span&gt; &lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CheckoutOverviewPage&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="c1"&gt;// ...one fixture per page object, plus the header component&lt;/span&gt;
  &lt;span class="na"&gt;scenario&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({},&lt;/span&gt; &lt;span class="nx"&gt;use&lt;/span&gt;&lt;span class="p"&gt;)&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;await&lt;/span&gt; &lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;cartProducts&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;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Given&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;When&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Then&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createBdd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;scenario&lt;/code&gt; fixture replaces the Cucumber "world". It holds state shared between the steps of one scenario, here the products added to the cart, and because it is test-scoped it cannot leak into another test. A step asks only for what it needs:&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="nc"&gt;When&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;I add {string} to the cart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;inventoryPage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;scenario&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;productName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&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;await&lt;/span&gt; &lt;span class="nx"&gt;inventoryPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addToCart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;scenario&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cartProducts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nc"&gt;Then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;the order overview lists the same products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;checkoutOverviewPage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;scenario&lt;/span&gt; &lt;span class="p"&gt;})&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;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;checkoutOverviewPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productNames&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scenario&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cartProducts&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;No globals, no shared mutable module state, and every test runs in its own browser context, so &lt;code&gt;fullyParallel: true&lt;/code&gt; is safe. Page objects own locators (role and &lt;code&gt;data-test&lt;/code&gt; attributes, never CSS paths), and assertions are web-first, so there are no hard waits.&lt;/p&gt;

&lt;p&gt;Free-form strings from Gherkin are also narrowed at the boundary. &lt;code&gt;toSauceUser(username)&lt;/code&gt; turns &lt;code&gt;"standard_user"&lt;/code&gt; into a typed union member and throws with the list of known users if a scenario has a typo, instead of failing later on a confusing login error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Login: through the UI only where login is under test
&lt;/h2&gt;

&lt;p&gt;Logging in through the form in every scenario is slow and makes the login page a failure point for tests that have nothing to do with it. SauceDemo keeps its session in a plain &lt;code&gt;session-username&lt;/code&gt; cookie, so outside &lt;code&gt;login.feature&lt;/code&gt; the suite sets the cookie and opens the inventory page:&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;loginViaSession&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="nx"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SauceUser&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;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;context&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addCookies&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;session-username&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sauce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;baseUrl&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/inventory.html&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I validated this against the live site before relying on it, and the &lt;code&gt;Given I am logged in as "..."&lt;/code&gt; step still asserts that the Products page is shown. If the site ever stops honoring the cookie, only this function changes. &lt;code&gt;login.feature&lt;/code&gt; keeps a separate step, &lt;code&gt;Given I am logged in as "standard_user" using the login form&lt;/code&gt;, for the logout scenario, where the real form is part of what is being tested. In a real application the equivalent would be an API login or a stored &lt;code&gt;storageState&lt;/code&gt;. The principle is the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Asserting business rules, not just screens
&lt;/h2&gt;

&lt;p&gt;"The order total equals the item total plus tax" is a business rule, so the step must check the arithmetic, not only that a total is visible. Money is never compared as floats (&lt;code&gt;0.1 + 0.2 !== 0.3&lt;/code&gt;). Everything goes through integer cents:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toCents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sumCents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amounts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;amounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;toCents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nc"&gt;Then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;the order total equals the item total plus tax&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;checkoutOverviewPage&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;itemTotal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;checkoutOverviewPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;itemTotal&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;tax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;checkoutOverviewPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tax&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;toCents&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;checkoutOverviewPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;total&lt;/span&gt;&lt;span class="p"&gt;())).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sumCents&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;itemTotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tax&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 item total step does the same against the sum of the line prices. These helpers are pure functions, so they have their own unit tests in the plain Playwright &lt;code&gt;unit&lt;/code&gt; project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Known defects: tracked with &lt;a class="mentioned-user" href="https://dev.to/fail"&gt;@fail&lt;/a&gt;, not skipped
&lt;/h2&gt;

&lt;p&gt;SauceDemo's &lt;code&gt;problem_user&lt;/code&gt; is broken on purpose: every product shows the same placeholder image. Instead of skipping a test, I wrote the scenario that asserts the correct behavior and tagged it &lt;code&gt;@fail&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gherkin"&gt;&lt;code&gt;&lt;span class="nt"&gt;@fail&lt;/span&gt;
&lt;span class="kn"&gt;Scenario&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; problem_user sees a distinct image for each product
  &lt;span class="nf"&gt;Given &lt;/span&gt;I am logged in as &lt;span class="s"&gt;"problem_user"&lt;/span&gt;
  &lt;span class="nf"&gt;Then &lt;/span&gt;every product shows its own image
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;playwright-bdd turns &lt;code&gt;@fail&lt;/code&gt; into &lt;code&gt;test.fail()&lt;/code&gt; in the generated spec. Today the scenario is reported as an expected failure. If the defect is fixed, Playwright reports it as "unexpectedly passed", which is the signal to remove the tag. The defect stays visible in every run.&lt;/p&gt;

&lt;p&gt;The honest limitation: &lt;code&gt;@fail&lt;/code&gt; accepts any failure. If the site is down or a selector drifts, this scenario still "passes" as an expected failure. It relies on the other UI tests to prove the site is up. A stricter version would assert the specific failure, but for one tracked defect in a public sandbox I chose the simpler mechanism and documented the trade-off in &lt;code&gt;docs/KNOWN_ISSUES.md&lt;/code&gt;, along with the &lt;code&gt;problem_user&lt;/code&gt; defects I verified by hand but did not automate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the suite honest in CI
&lt;/h2&gt;

&lt;p&gt;A Gherkin suite rots quietly in two ways: a step loses its definition, or someone disables a scenario "for now". The CI pipeline has a dedicated &lt;code&gt;Gherkin&lt;/code&gt; job, which needs no browser and runs in parallel with lint and typecheck:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Fails fast if a Gherkin step has no definition.&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx bddgen&lt;/span&gt;
&lt;span class="c1"&gt;# Lint covers test.skip in TypeScript. This covers the same escape hatch in Gherkin.&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Reject disabled scenarios&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;if grep -rnE '(^|[[:space:]])@(skip|fixme|only)([[:space:]]|$)' features --include='*.feature'; then&lt;/span&gt;
      &lt;span class="s"&gt;echo '::error::A scenario is disabled with @skip, @fixme or @only. Fix it or track it with @fail.'&lt;/span&gt;
      &lt;span class="s"&gt;exit 1&lt;/span&gt;
    &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The error message states the policy: fix it, or track it with &lt;code&gt;@fail&lt;/code&gt;. Only after this and the other fast checks pass do the browser tests run, sharded in two, with blob reports merged into a single HTML report.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use Gherkin where someone outside engineering will read it. Keep API, accessibility and unit tests in plain Playwright.&lt;/li&gt;
&lt;li&gt;Run Gherkin on the Playwright runner. You keep fixtures, parallelism, traces, reports and sharding instead of rebuilding them.&lt;/li&gt;
&lt;li&gt;Write declarative steps. Spell out values only when the variation is the business rule, and use Scenario Outlines for that.&lt;/li&gt;
&lt;li&gt;Inject page objects and scenario state as fixtures. No globals, no shared world object.&lt;/li&gt;
&lt;li&gt;Never skip silently: track known defects with &lt;code&gt;@fail&lt;/code&gt;, know its limits, and let CI reject &lt;code&gt;@skip&lt;/code&gt;, &lt;code&gt;@fixme&lt;/code&gt; and &lt;code&gt;@only&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full project, including the API and accessibility suites, is on GitHub: &lt;a href="https://github.com/tayguara/playwright-qa-showcase" rel="noopener noreferrer"&gt;github.com/tayguara/playwright-qa-showcase&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Tayguara Dias Reis is a Lead QA / SDET with 14+ years of experience and ISTQB CTFL certification. The code in this article is from &lt;a href="https://github.com/tayguara/playwright-qa-showcase" rel="noopener noreferrer"&gt;playwright-qa-showcase&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>testing</category>
      <category>bdd</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
