<?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: Kirill</title>
    <description>The latest articles on DEV Community by Kirill (@marshlw).</description>
    <link>https://dev.to/marshlw</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%2F4226159%2F467845bf-d002-445b-8165-51a0bf1500b2.jpg</url>
      <title>DEV Community: Kirill</title>
      <link>https://dev.to/marshlw</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/marshlw"/>
    <language>en</language>
    <item>
      <title>Playwright for Python has no toHaveScreenshot, so I wrote a visual testing library</title>
      <dc:creator>Kirill</dc:creator>
      <pubDate>Wed, 04 Nov 2026 12:17:57 +0000</pubDate>
      <link>https://dev.to/marshlw/playwright-for-python-has-no-tohavescreenshot-so-i-wrote-a-visual-testing-library-1fe5</link>
      <guid>https://dev.to/marshlw/playwright-for-python-has-no-tohavescreenshot-so-i-wrote-a-visual-testing-library-1fe5</guid>
      <description>&lt;p&gt;I write test automation in Python: pytest, Playwright, CI. And from project to project I keep seeing the same picture. API tests, UI tests, reports: there is a good tool for all of it. But the moment you need to check how a page &lt;em&gt;looks&lt;/em&gt;, the workarounds begin.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was missing
&lt;/h2&gt;

&lt;p&gt;Playwright for JavaScript has &lt;code&gt;toHaveScreenshot()&lt;/code&gt;: one line, and the test compares the page with a baseline. Playwright for Python has nothing like it. When I started, the options were thin: a couple of small plugins on top of pixel-by-pixel comparison, or a home-grown wrapper that every team writes from scratch.&lt;/p&gt;

&lt;p&gt;And pixel-by-pixel comparison breaks in two ways.&lt;/p&gt;

&lt;p&gt;The first: the test goes red although the page did not change. A font rendered half a pixel differently, CI ran on another machine. The team puts up with it for a week, then someone adds automatic baseline updates to the pipeline. Visual tests still exist on paper, but in practice they are switched off.&lt;/p&gt;

&lt;p&gt;The second: to stop the red, people set a threshold like "98% similar". And the test stops seeing small things. But a UI breaks exactly in small things: the wrong checkbox state, the wrong button colour, a border off by one pixel.&lt;/p&gt;

&lt;p&gt;I have seen this first-hand. In one project a screenshot suite had been green for six months. It compared the whole frame with a 0.98 threshold. And all that time it was missing a real bug. More on that below.&lt;/p&gt;

&lt;p&gt;What I wanted was simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pip install&lt;/code&gt;, one function in the test, baselines in the repository;&lt;/li&gt;
&lt;li&gt;the test does not fail when the page is merely redrawn a little differently;&lt;/li&gt;
&lt;li&gt;the test does fail on a small real change;&lt;/li&gt;
&lt;li&gt;and most importantly, it says in words what changed: "button recoloured", "block moved by one pixel", not "3.2% mismatch".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is how VisTest started.&lt;/p&gt;

&lt;h2&gt;
  
  
  First I built the wrong thing
&lt;/h2&gt;

&lt;p&gt;Let me admit it right away: the first version was not a library but a platform. A server, a review web UI, run history, a mouse recorder for tests, single sign-on. I thought the product was all of that, and image comparison was a detail inside that already worked.&lt;/p&gt;

&lt;p&gt;Then I counted. The comparison itself was about a tenth of the code. The other nine tenths served an engine I had never tested on real screenshots, only on pictures drawn by my own code.&lt;/p&gt;

&lt;p&gt;I realised it when I tried to plug the tool into someone else's project. You cannot pip-install a platform into someone else's tests. So VisTest narrowed down to what I should have started with: a library and a pytest plugin. The team server did not go away, but it became a separate product for later.&lt;/p&gt;

&lt;h2&gt;
  
  
  First contact with a real project
&lt;/h2&gt;

&lt;p&gt;The pilot was an internal Playwright project with 11 screenshot tests. There was one piece of good news, and it mattered: adoption came down to replacing a single wrapper method. The tests themselves did not change by a single line.&lt;/p&gt;

&lt;p&gt;Then, within the first few minutes, we found more problems than in the whole development so far. The three most telling ones:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One difference killed the whole run.&lt;/strong&gt; The fifth test found a real mismatch, and pytest crashed entirely with an internal error. The other six tests never ran, and there was no report. A tool meant to help find regressions buried the run at the exact moment it found one. Now the rule is simple: a configuration error is loud and immediate; a failure in an optional part is a warning, and the tests carry on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The numbers in the message did not add up.&lt;/strong&gt; "1.44% of the frame changed", while the regions it named added up to dozens of times less. A person reading a CI log cannot reconcile numbers like that, and stops trusting both. Now the area is broken into parts that always add up to the total.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The engine erased text.&lt;/strong&gt; To remove noise, the change mask was cleaned with a morphological filter, and along with the noise it threw away everything thinner than five pixels. Which is almost all text. On one pair of images, 97% of a real change disappeared before the engine even started looking at it. I changed the order of operations and gave noise its own explanations. Now every discarded difference is labelled with the rule that discarded it.&lt;/p&gt;

&lt;p&gt;None of these bugs would have been caught by my own tests, and by then I had more than a thousand of them. Your own tests check what you thought of. Someone else's project checks what nobody thought of.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it was all for
&lt;/h2&gt;

&lt;p&gt;On the second run, VisTest found a real bug in that very suite that had been green for six months. The state of a filter panel was carrying over from one page to another when it should not have. The difference sat on 35-pixel checkboxes. A whole-frame comparison with a 0.98 threshold could never see it: 35 pixels on a full-screen screenshot barely move the overall "similarity".&lt;/p&gt;

&lt;p&gt;One finding across eleven tests is not statistics, and I do not conclude from it that "VisTest is better". But it shows the problem is real: a whole class of changes slips past an ordinary check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the project is now
&lt;/h2&gt;

&lt;p&gt;VisTest is on PyPI as an alpha, open source under the Apache-2.0 licence.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"vistest[browser]"&lt;/span&gt;
playwright &lt;span class="nb"&gt;install &lt;/span&gt;chromium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;vistest&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;expect_screenshot&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_landing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;expect_screenshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;landing.png&lt;/span&gt;&lt;span class="sh"&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 first run is red: there is no baseline yet. &lt;code&gt;pytest --vistest-update&lt;/code&gt; writes the baselines; you look at them and commit them. From then on the test compares, and if something changed, it explains what and where.&lt;/p&gt;

&lt;p&gt;There are no comparison numbers against other tools here, on purpose. The next posts are about exactly that: how I measured my engine, how I fooled myself with nice-looking results, and how I then lost to Playwright on real browser screenshots. That story is more useful than any victory table.&lt;/p&gt;

&lt;p&gt;If you write tests in Python and have also been looking for a &lt;code&gt;toHaveScreenshot&lt;/code&gt; replacement, give VisTest a try and tell me what broke. Someone else's project, as it turned out, is the best test.&lt;/p&gt;

&lt;p&gt;Next in the series…&lt;/p&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/marshlw/vistest" rel="noopener noreferrer"&gt;github.com/marshlw/vistest&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>testing</category>
      <category>playwright</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
