-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtutorial.html
More file actions
1633 lines (1582 loc) · 83.7 KB
/
Copy pathtutorial.html
File metadata and controls
1633 lines (1582 loc) · 83.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tutorial: Playwright UI Testing from Scratch</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #ffffff;
color: #1f2328;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
line-height: 1.7;
padding: 40px 20px;
}
.container { max-width: 880px; margin: 0 auto; }
h1, h2, h3 { color: #1f2328; margin-top: 2em; }
h1 { font-size: 2em; border-bottom: 1px solid #d0d7de; padding-bottom: 0.3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid #d8dee4; padding-bottom: 0.2em; }
p { margin: 1em 0; }
ul, ol { padding-left: 1.5em; margin: 0.5em 0; }
li { margin: 0.3em 0; }
hr { border: none; border-top: 1px solid #d0d7de; margin: 2em 0; }
blockquote {
border-left: 4px solid #d0d7de;
padding: 0.5em 1em;
margin: 1em 0;
color: #656d76;
}
/* Dark code blocks — keep the VS Code look */
pre {
background: #1e1e1e !important;
border-radius: 6px;
padding: 16px;
overflow-x: auto;
margin: 1em 0;
}
pre code {
font: 14px/1.5 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
background: none !important;
padding: 0 !important;
color: #d4d4d4;
}
/* Inline code — light, readable */
code {
background: #f3f4f6;
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: 0.9em;
color: #1f2328;
}
table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
th, td {
border: 1px solid #d0d7de;
padding: 8px 12px;
text-align: left;
}
th { background: #f6f8fa; color: #1f2328; }
a { color: #0969da; }
a:hover { text-decoration: underline; }
blockquote p { margin: 0.3em 0; }
</style>
</head>
<body>
<div class="container"><h1>Tutorial: Playwright UI Testing from Scratch</h1>
<p><strong>Target audience</strong>: Complete beginners — no Playwright, no TypeScript knowledge assumed.<br>
<strong>Format</strong>: Short, focused lessons with one takeaway each. Libraries installed incrementally as they're introduced.<br>
<strong>Application Under Test</strong>: <a href="https://www.saucedemo.com" target="_blank">https://www.saucedemo.com</a> — an official testing website provided by <strong>Sauce Labs</strong> (the company behind the cross-browser testing platform). It's a mock e-commerce site built specifically for practicing test automation with known users and deliberate error states.</p>
<h2>Table of Contents</h2>
<table>
<thead>
<tr>
<th>Lesson</th>
<th>Topic</th>
<th>Time</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>What is Playwright?</td>
<td>15 min</td>
</tr>
<tr>
<td>2</td>
<td>Environment Setup</td>
<td>30 min</td>
</tr>
<tr>
<td>3</td>
<td>What Did That Command Create?</td>
<td>20 min</td>
</tr>
<tr>
<td>4</td>
<td>Your First Playwright Test</td>
<td>55 min</td>
</tr>
<tr>
<td>5</td>
<td>Test Runner Basics</td>
<td>40 min</td>
</tr>
<tr>
<td>6</td>
<td>Locators</td>
<td>65 min</td>
</tr>
<tr>
<td>7</td>
<td>iframes & Shadow DOM</td>
<td>20 min</td>
</tr>
<tr>
<td>8</td>
<td>Actions</td>
<td>35 min</td>
</tr>
<tr>
<td>9</td>
<td>Assertions & Auto-Waiting</td>
<td>45 min</td>
</tr>
<tr>
<td>10</td>
<td>Test Structure & Hooks</td>
<td>25 min</td>
</tr>
<tr>
<td>11</td>
<td>Page Object Model</td>
<td>60 min</td>
</tr>
<tr>
<td>12</td>
<td>Custom Fixtures</td>
<td>25 min</td>
</tr>
<tr>
<td>13</td>
<td>Environment Variables (dotenv)</td>
<td>20 min</td>
</tr>
<tr>
<td>14</td>
<td>BDD (playwright-bdd)</td>
<td>65 min</td>
</tr>
<tr>
<td>15</td>
<td>Allure Reporting</td>
<td>25 min</td>
</tr>
<tr>
<td>16</td>
<td>Cleaning Up the Config</td>
<td>15 min</td>
</tr>
<tr>
<td>17</td>
<td>Full Framework & Best Practices</td>
<td>30 min</td>
</tr>
<tr>
<td>18</td>
<td>Running Tests</td>
<td>10 min</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td></td>
<td><strong>~10 hours</strong></td>
</tr>
</tbody></table>
<hr>
<h2>Lesson 1 — What is Playwright & Why Test Automation?</h2>
<p><strong>Time</strong>: 15 min </p>
<ul>
<li><strong>What is Playwright?</strong> — an open-source tool from Microsoft for automating web browsers. You write code that controls a browser just like a human would: clicking buttons, typing text, reading page content. Official docs: <a href="https://playwright.dev/docs/intro">https://playwright.dev/docs/intro</a></li>
<li><strong>Why automate tests?</strong><ul>
<li>Manual testing is slow and repetitive — you'd have to log in, click around, and check results by hand every time</li>
<li>Automated tests run in seconds and catch regressions before they reach users</li>
<li>They run the same way every time — no human error or fatigue</li>
</ul>
</li>
<li><strong>What can Playwright do?</strong><ul>
<li>Run tests in Chromium (Chrome/Edge), Firefox, and WebKit (Safari) — one API, three browsers</li>
<li>Take screenshots, record videos, capture network traffic</li>
<li>Run tests in parallel — 10 tests finish as fast as the slowest one, not the sum of all 10</li>
<li>Generate traces (a full debug log of every action, network request, and console message)</li>
</ul>
</li>
<li><strong>What you'll build</strong> — by the end of this course, you'll have a professional test framework with:<ul>
<li>Page Object Model (POM) — clean, reusable test code</li>
<li>BDD (Gherkin) — plain-English test scenarios</li>
<li>Custom fixtures — reusable test setup</li>
<li>Allure reporting — rich, interactive test reports</li>
<li>CI-ready configuration</li>
</ul>
</li>
</ul>
<hr>
<h2>Lesson 2 — Setting Up the Environment</h2>
<p><strong>Time</strong>: 30 min<br><strong>Install</strong>: Node.js, npm, VS Code or Cursor</p>
<ul>
<li><p><strong>What is Node.js?</strong> — a JavaScript runtime. It lets you run JavaScript on your computer (not just in a browser). Playwright is a Node.js library.</p>
</li>
<li><p><strong>What is npm?</strong> — Node Package Manager. It downloads and manages libraries (dependencies) for your project.</p>
</li>
<li><p><strong>Install Node.js and npm</strong> (if not already installed):</p>
<ol>
<li>Go to <a href="https://nodejs.org">https://nodejs.org</a> (the LTS version is recommended)</li>
<li>Download the Windows installer (.msi) and run it</li>
<li>Follow the installer — leave all defaults checked (it adds <code>node</code> and <code>npm</code> to your PATH automatically)</li>
<li>After installation, <strong>restart your terminal</strong> (or VS Code/Cursor) so the new PATH takes effect</li>
</ol>
</li>
<li><p><strong>Install an editor</strong> — you need a code editor to write and manage your test files. Two good free options:</p>
<ul>
<li><strong>VS Code</strong> — download from <a href="https://code.visualstudio.com">https://code.visualstudio.com</a>. The installer is straightforward — keep all defaults.</li>
<li><strong>Cursor</strong> — download from <a href="https://www.cursor.com">https://www.cursor.com</a>. It's built on VS Code with AI features built in. Same look and feel.</li>
</ul>
</li>
<li><p><strong>Verify everything is installed</strong> — open a terminal (Command Prompt, PowerShell, or your editor's built-in terminal) and run:</p>
<pre><code class="language-bash">node --version # should show v20 or higher
npm --version # should show 10 or higher
code --version # if using VS Code — should show a version number
</code></pre>
<p>If <code>node</code> or <code>npm</code> is not recognized, restart your terminal and try again. If it still fails, restart your computer so the PATH change takes effect.</p>
</li>
<li><p><strong>Create the project</strong> — one command scaffolds everything:</p>
<pre><code class="language-bash">mkdir playwright-ui-testing
cd playwright-ui-testing
npm init playwright@latest
</code></pre>
<ul>
<li>You'll be asked: "Do you want to use TypeScript?" — select <strong>Yes</strong></li>
<li>"Where to put your tests?" — keep the default <code>tests/</code></li>
<li>"Add a GitHub Actions workflow?" — <strong>No</strong> (we'll cover CI separately)</li>
<li>"Install Playwright browsers?" — <strong>Yes</strong> (downloads Chromium, Firefox, WebKit)</li>
</ul>
</li>
<li><p><strong>What just happened?</strong> — the command created:</p>
<ul>
<li><code>package.json</code> — lists dependencies and scripts</li>
<li><code>playwright.config.ts</code> — central configuration file</li>
<li><code>tests/</code> — folder with a sample test</li>
<li><code>node_modules/</code> — downloaded libraries (don't touch this)</li>
<li>Browsers installed in a system cache</li>
</ul>
</li>
<li><p><strong>Open in your editor</strong>:</p>
<ul>
<li>VS Code: run <code>code .</code> in the project folder, or open VS Code and use File → Open Folder</li>
<li>Cursor: run <code>cursor .</code> in the project folder, or open Cursor and use File → Open Folder</li>
</ul>
</li>
</ul>
<h2>Lesson 3 — What Did That Command Create?</h2>
<p><strong>Time</strong>: 20 min</p>
<p>After running <code>npm init playwright@latest</code>, you'll see these files/folders. Here's what each one is:</p>
<ul>
<li><p><strong><code>playwright.config.ts</code></strong> — the main configuration file. It tells Playwright where your tests are, which browsers to use, and other settings like timeouts and screenshots.</p>
</li>
<li><p><strong><code>package.json</code></strong> — the project manifest. It lists the libraries your project depends on (<code>@playwright/test</code>), scripts you can run (<code>npx playwright test</code>), and metadata about the project.</p>
</li>
<li><p><strong><code>node_modules/</code></strong> — the folder where npm downloads and stores all the installed libraries. You never edit this folder manually. It should stay in <code>.gitignore</code>.</p>
</li>
<li><p><strong><code>tests/</code></strong> — the default folder where Playwright looks for test files (files ending in <code>.spec.ts</code> or <code>.test.ts</code>). You can change this in the config.</p>
</li>
<li><p><strong><code>playwright-report/</code></strong> — generated after a test run; contains the HTML report you can open in a browser.</p>
</li>
<li><p><strong><code>tsconfig.json</code></strong> (optional) — TypeScript configuration. <code>npm init playwright@latest</code> may not generate this file. If you want to add TypeScript-specific settings (strict mode, custom include paths), create it at the project root:</p>
<pre><code class="language-json">{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"outDir": "./out",
"types": ["node"]
},
"include": ["src/**/*.ts", "playwright.config.ts"],
"exclude": ["node_modules"]
}
</code></pre>
<p>Key settings:</p>
<ul>
<li><code>target: ES2022</code>, <code>module: commonjs</code> — compiles modern TypeScript to Node-compatible JavaScript</li>
<li><code>strict: true</code> — catches more errors at compile time (e.g., null checks)</li>
<li><code>include</code> — which files TypeScript type-checks (your source files + config)</li>
<li>You rarely touch this file, but it's essential for IDE type-checking.</li>
</ul>
</li>
<li><p><strong><code>.gitignore</code></strong> — tells Git which files/folders to ignore. </p>
<p><strong>What is Git?</strong> Git is a tool that tracks changes to your files and lets you upload your project to hosting platforms like GitHub or GitLab. This means your code lives on the cloud — safe if your laptop breaks, easy to share with teammates, and you can rewind to any previous version if something breaks.</p>
<p><code>.gitignore</code> tells Git "don't upload these files" — things like secrets (<code>.env</code>), downloaded libraries (<code>node_modules/</code>), and generated reports. The <code>npm init</code> command creates a basic <code>.gitignore</code>. By the end of this course it will look like this:</p>
<pre><code>node_modules/
/playwright-report/
/test-results/
/allure-results/
/allure-report/
/.features-gen/
/playwright/.cache/
/playwright/.auth/
/.env
</code></pre>
<p>Generated folders (<code>allure-results/</code>, <code>.features-gen/</code>, <code>playwright-report/</code>) don't need to be in Git — they're rebuilt when you run tests. Secrets (<code>.env</code>) stay local. Don't worry about memorizing these now; each entry will make sense when we add the tool that creates it.</p>
</li>
</ul>
<hr>
<h2>Lesson 4 — Your First Playwright Test</h2>
<p><strong>Time</strong>: 55 min </p>
<ul>
<li><p>What is a test file? A file ending in <code>.spec.ts</code> or <code>.test.ts</code>. <code>.ts</code> means TypeScript (JavaScript with types). Playwright automatically finds these files and runs them.</p>
</li>
<li><p>Walk through the simplest test line by line:</p>
<pre><code class="language-ts">import { test, expect } from '@playwright/test';
</code></pre>
<p>This pulls in the two things you need: <code>test</code> (to define a test) and <code>expect</code> (to check things).</p>
<pre><code class="language-ts">test('has title', async ({ page }) => {
</code></pre>
<ul>
<li><code>test('has title', ...)</code> — defines a test named "has title"</li>
<li><code>async</code> — this function will use <code>await</code> inside (needed for operations that take time, like loading a page)</li>
<li><code>({ page })</code> — Playwright gives your test a <code>page</code> object. The <code>page</code> represents a browser tab. The curly braces <code>{}</code> with <code>page</code> inside is called <strong>destructuring</strong> — you're pulling the <code>page</code> fixture out of the built-in set of tools Playwright provides.</li>
</ul>
<pre><code class="language-ts">await page.goto('https://www.saucedemo.com');
</code></pre>
<ul>
<li><code>page.goto(...)</code> — tells the browser tab to navigate to a URL</li>
<li><code>await</code> — wait for the page to finish loading before moving to the next line</li>
<li><strong>Application Under Test</strong>: we're using <a href="https://www.saucedemo.com" target="_blank">https://www.saucedemo.com</a> — an official testing website provided by <strong>Sauce Labs</strong> (the company behind the cross-browser testing platform). It's a mock e-commerce site built specifically for practicing test automation. It has known users, expected behaviors, and deliberate error states — perfect for learning without needing your own app.</li>
</ul>
<pre><code class="language-ts">test('has title', async ({ page }) => {
await page.goto('https://www.saucedemo.com');
await expect(page).toHaveTitle('Swag Labs');
});
</code></pre>
<ul>
<li><code>expect(page)</code> — "I expect this page to have a certain property"</li>
<li><code>.toHaveTitle('Swag Labs')</code> — check that the page's <code><title></code> tag matches "Swag Labs"</li>
<li>If it doesn't match, the test fails</li>
</ul>
</li>
<li><p><strong>Set <code>baseURL</code></strong> — instead of writing the full URL in every test, set it once in <code>playwright.config.ts</code>:</p>
<pre><code class="language-ts">export default defineConfig({
use: { baseURL: 'https://www.saucedemo.com' },
});
</code></pre>
<p>Now tests can use relative paths:</p>
<pre><code class="language-ts">await page.goto('/'); // same as 'https://www.saucedemo.com'
</code></pre>
<p>One change in config updates every test — much cleaner.</p>
</li>
<li><p>Run it: <code>npx playwright test</code></p>
<p>By default Playwright configures <strong>3 projects</strong> — Chromium, Firefox, and WebKit — so your single test runs <strong>three times</strong>, once in each browser. You'll see output like:</p>
<pre><code> ✓ 1 [chromium] › test.spec.ts:3:6 › has title
✓ 2 [firefox] › test.spec.ts:3:6 › has title
✓ 3 [webkit] › test.spec.ts:3:6 › has title
</code></pre>
<p>This is great for cross-browser coverage, but for now keep things simple by removing Firefox and WebKit and keeping only Chromium. Open <code>playwright.config.ts</code> and replace the <code>projects</code> block with:</p>
<pre><code class="language-ts">projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1080 },
},
},
],
</code></pre>
<p>Setting the viewport ensures your tests always run at a consistent screen size, so layout-dependent assertions don't randomly fail on smaller windows.</p>
<pre><code class="language-ts">import { defineConfig, devices } from '@playwright/test';
</code></pre>
<p>Now your test runs only in Chromium, once.</p>
<ul>
<li><code>npx</code> runs a command from the local project (not globally installed)</li>
<li>If it passes, you get a green checkmark. If it fails, you get a red X with details on what went wrong.</li>
</ul>
</li>
<li><p>What you're seeing in the terminal is the <strong>list reporter</strong> — it prints each test name and result as they run. Playwright also generates an <strong>HTML report</strong> in <code>playwright-report/</code> that you can open with <code>npx playwright show-report</code>.</p>
<p>Configure reporters in <code>playwright.config.ts</code>:</p>
<pre><code class="language-ts">reporter: [
['list'], // live terminal output
['html'], // browsable HTML report
],
</code></pre>
<p>You can add more reporters later (like Allure) without removing these.</p>
</li>
<li><p>View the HTML report: <code>npx playwright show-report</code></p>
<ul>
<li>This opens <code>playwright-report/index.html</code> in your browser</li>
<li>Shows all tests, pass/fail status, how long each took, and error details on failures</li>
<li>Only opens manually — Playwright doesn't auto-open it when all tests pass</li>
</ul>
</li>
<li><p><strong>Playwright VS Code extension</strong> — install "Playwright Test for VS Code" by Microsoft from the marketplace. It adds a testing sidebar where you can:</p>
<ul>
<li>Run or debug individual tests with one click (no terminal commands)</li>
<li>See pass/fail status inline in your test file</li>
<li>Pick locators by clicking elements in the browser</li>
<li>Record test actions (codegen) directly from VS Code</li>
<li>Great for beginners — less terminal, more visual feedback</li>
</ul>
</li>
</ul>
<hr>
<h2>Lesson 5 — Test Runner Basics</h2>
<p><strong>Time</strong>: 40 min </p>
<ul>
<li><p><code>test()</code> — defines a test case. <code>describe()</code> — groups related tests together.</p>
<pre><code class="language-ts">import { test, expect } from '@playwright/test';
describe('Login', () => {
test('works with valid credentials', async ({ page }) => {
await page.goto('https://www.saucedemo.com');
// ... test steps
});
test('fails with wrong password', async ({ page }) => {
await page.goto('https://www.saucedemo.com');
// ... test steps
});
test('fails with wrong password', async ({ page }) => {
await page.goto('https://www.saucedemo.com');
// ... test steps
});
});
</code></pre>
</li>
<li><p><strong>Tagging non-BDD tests</strong> — add tags to tests so you can filter them in the HTML report or CLI. Pass a <code>tag</code> option to <code>test()</code>:</p>
<pre><code class="language-ts">test('works with valid credentials', { tag: ['@smoke', '@core', '@non-bdd-tests'] }, async ({ page }) => {
// ...
});
test('fails with wrong password', { tag: ['@extended', '@non-bdd-tests'] }, async ({ page }) => {
// ...
});
</code></pre>
<p>Tags appear in the HTML report filter. Run by tag: <code>npx playwright test --grep "@smoke"</code></p>
</li>
<li><p><code>expect()</code> — makes an assertion. If it's false, the test fails. Common matchers:</p>
<pre><code class="language-ts">expect('hello').toBe('hello'); // exact match
expect('hello world').toContain('world'); // partial match
expect(2 + 2).toEqual(4); // deep equality (objects, arrays)
expect(true).toBeTruthy(); // truthy check
</code></pre>
</li>
<li><p><code>async/await</code> — Playwright actions take time (loading pages, waiting for elements). <code>await</code> pauses until the action finishes:</p>
<pre><code class="language-ts">// Wrong (test will fail before navigation completes):
test('wrong', ({ page }) => {
page.goto('https://www.saucedemo.com'); // missing await
});
// Right:
test('right', async ({ page }) => {
await page.goto('https://www.saucedemo.com');
});
</code></pre>
</li>
<li><p>Demo: write a file with 3 tests — 2 that pass, 1 that fails on purpose — and watch the output show which passed and which didn't</p>
</li>
</ul>
<hr>
<h2>Lesson 6 — Locators: Finding Elements</h2>
<p><strong>Time</strong>: 65 min </p>
<ul>
<li><p>What is a locator? (a way to tell Playwright which element on the page to interact with)</p>
</li>
<li><p><strong>Playwright's own locators</strong> — preferred because they're built into the framework, resilient to DOM changes, and mimic how users find things:</p>
<p><code>getByRole</code> — finds elements by their ARIA role (button, heading, link, textbox, etc.):</p>
<pre><code class="language-html"><button>Login</button>
</code></pre>
<pre><code class="language-ts">await page.getByRole('button', { name: 'Login' }).click();
</code></pre>
<p>This is the #1 recommended locator. It finds the button by its role (<code>button</code>) and its accessible name (<code>Login</code>). Works for links (<code>'link'</code>), text inputs (<code>'textbox'</code>), checkboxes (<code>'checkbox'</code>), headings (<code>'heading'</code>), and more.</p>
<p><code>getByText</code> — finds by visible text content:</p>
<pre><code class="language-html"><div>Welcome back, User!</div>
</code></pre>
<pre><code class="language-ts">await expect(page.getByText('Welcome back')).toBeVisible();
</code></pre>
<p>Useful for paragraphs, divs, spans — anything where you can see the text but there's no label or role.</p>
<p><code>getByLabel</code> — finds form inputs by their <code><label></code> element:</p>
<pre><code class="language-html"><label for="email">Email</label> <input id="email" />
</code></pre>
<pre><code class="language-ts">await page.getByLabel('Email').fill('user@example.com');
</code></pre>
<p>Perfect for form fields. The <code><label></code> can wrap the input or use the <code>for</code> attribute — Playwright handles both.</p>
<p><code>getByPlaceholder</code> — finds inputs by their placeholder text:</p>
<pre><code class="language-html"><input placeholder="Enter your name" />
</code></pre>
<pre><code class="language-ts">await page.getByPlaceholder('Enter your name').fill('Alice');
</code></pre>
<p>Good for login forms and search bars where placeholders are unique.</p>
<p><code>getByTestId</code> — finds by a custom data attribute (requires config):</p>
<pre><code class="language-html"><button data-testid="submit-btn">Submit</button>
</code></pre>
<pre><code class="language-ts">await page.getByTestId('submit-btn').click();
</code></pre>
<p>Most stable but requires developers to add <code>data-testid</code> attributes. Playwright uses <code>data-testid</code> by default — no config needed. If your team uses a different attribute (e.g., <code>data-test</code> or <code>data-cy</code>), set it in <code>playwright.config.ts</code> under <code>testIdAttribute</code>:</p>
</li>
<li><p><strong>Fallback locators</strong> — CSS and XPath, use when Playwright locators can't express what you need:</p>
<pre><code class="language-ts">page.locator('#username'); // CSS by id
page.locator('.card'); // CSS by class
page.locator('button.primary'); // CSS by tag + class
page.locator('[data-type="user"]'); // CSS by any attribute
page.locator('xpath=//button[text()="Login"]'); // XPath
</code></pre>
</li>
<li><p><strong>Chaining & filtering</strong> — narrow down when multiple elements match:</p>
<pre><code class="language-ts">page.getByRole('listitem').filter({ hasText: 'Apple' });
page.locator('.product-row').filter({ has: page.getByRole('button') });
page.getByRole('button').first();
page.getByRole('button').last();
page.getByRole('button').nth(2);
</code></pre>
</li>
<li><p><strong>Why prefer Playwright locators over CSS/XPath?</strong></p>
<ul>
<li>Auto-waiting — they wait for the element to be visible and enabled before acting</li>
<li>Accessibility-first — they find elements the way users and screen readers do</li>
<li>Less brittle — a CSS class change won't break <code>getByRole</code>, but will break <code>.locator('.btn-primary')</code></li>
</ul>
</li>
<li><p><strong>Playwright Test Generator (codegen)</strong> — if manually writing locators feels overwhelming, Playwright can generate the code for you. You just click around in a browser, and it writes the test.</p>
<p><strong>How to use it:</strong></p>
<ol>
<li>Run this command in your terminal:<pre><code class="language-bash">npx playwright codegen https://www.saucedemo.com
</code></pre>
</li>
<li>Two windows appear side by side:<ul>
<li><strong>Left</strong>: the browser — you interact with the page normally (click, type, select)</li>
<li><strong>Right</strong>: the code panel — Playwright writes the equivalent test code in real time</li>
</ul>
</li>
<li>Every action you take generates a line of code. For example:<ul>
<li>Click the Username field → <code>page.getByPlaceholder('Username').click()</code></li>
<li>Type <code>standard_user</code> → <code>page.getByPlaceholder('Username').fill('standard_user')</code></li>
<li>Click the Login button → <code>page.getByRole('button', { name: 'Login' }).click()</code></li>
</ul>
</li>
<li>Once you've recorded the flow, copy the code from the panel and paste it into your test file</li>
</ol>
<p><strong>What if you make a wrong click?</strong> — just clear the generated code with the "Clear" button in the code panel and start over. No harm done.</p>
<p><strong>Using codegen from VS Code (or Cursor)</strong> — the Playwright VS Code extension (installed in Lesson 4) has codegen built in:</p>
<ul>
<li>Open a test file</li>
<li>Right-click anywhere in the file</li>
<li>Select <strong>"Record at cursor"</strong> — the same browser + code panel opens, but the generated code is inserted directly into your file at the cursor position</li>
</ul>
<p><strong>When to use codegen:</strong></p>
<ul>
<li>Learning what locators Playwright finds for an element — instead of guessing, just click it and see what Playwright picks</li>
<li>Quickly prototyping a test scenario — generate the rough flow, then clean it up later</li>
<li>When you're stuck on a tricky element — codegen almost always finds a working locator</li>
</ul>
<blockquote>
<p><strong>Tip</strong>: codegen generates working code, but it tends to be verbose with extra <code>click()</code> calls. Use it as a starting point — then simplify by removing unnecessary steps and using direct actions like <code>fill()</code> instead of <code>click()</code> + <code>type()</code>.</p>
</blockquote>
<p><strong>Try it now</strong>: run <code>npx playwright codegen https://www.saucedemo.com</code>, log in with <code>standard_user</code> / <code>secret_sauce</code>, and watch Playwright write the entire login test for you in seconds.</p>
</li>
<li><p>Demo: open a real page (e.g., saucedemo.com), use DevTools to inspect elements, write locators for username field, password field, and login button using <code>getByRole</code>, <code>getByPlaceholder</code>, and <code>getByLabel</code>. Then chain locators to find specific items in a list.</p>
</li>
</ul>
<hr>
<h2>Lesson 7 — iframes & Shadow DOM</h2>
<p><strong>Time</strong>: 20 min </p>
<ul>
<li><p><strong>iframes</strong>: an <code><iframe></code> is a whole HTML page embedded inside another page. Normal locators on <code>page</code> can't see inside it.</p>
<p>Example page with an iframe:</p>
<pre><code class="language-html"><iframe id="payment-widget" src="https://checkout.example.com"></iframe>
</code></pre>
<pre><code class="language-ts">// Step 1 — create a FrameLocator targeting the iframe
const paymentFrame = page.frameLocator('#payment-widget');
// Step 2 — use the same locator methods on the frame
await paymentFrame.getByPlaceholder('Card number').fill('4242 4242 4242 4242');
await paymentFrame.getByPlaceholder('MM/YY').fill('12/28');
await paymentFrame.getByRole('button', { name: 'Pay' }).click();
</code></pre>
<p>Key points:</p>
<ul>
<li><code>frameLocator</code> supports ALL the same locators: <code>getByRole</code>, <code>getByText</code>, <code>getByLabel</code>, <code>getByPlaceholder</code>, <code>locator()</code>, etc.</li>
<li>You can chain like <code>page.frameLocator('#a').frameLocator('#b')</code> for nested iframes</li>
<li>For a plain <code><iframe></code> without <code>id</code> or <code>src</code>, use <code>page.frameLocator('css=iframe')</code></li>
</ul>
</li>
<li><p><strong>Shadow DOM</strong>: encapsulated DOM attached to a regular element (used by web components). Playwright handles it automatically — no extra code needed.</p>
<pre><code class="language-html"><my-button>Submit</my-button>
<script>
class MyButton extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<button>${this.textContent}</button>`;
}
}
customElements.define('my-button', MyButton);
</script>
</code></pre>
<pre><code class="language-ts">// Playwright sees through shadow DOM automatically:
await page.getByRole('button', { name: 'Submit' }).click();
// This works even though <button> is inside the shadow root
</code></pre>
<p><strong>Important</strong>: this only works with <strong>open</strong> shadow DOM (<code>mode: 'open'</code>). If the component uses <code>mode: 'closed'</code>, Playwright can't reach inside — the element is invisible to locators and even <code>>></code> won't help. In that case:</p>
<ul>
<li>Ask the developers to switch to <code>mode: 'open'</code> (it's the recommended setting for testability)</li>
<li>If that's not possible, use <code>page.evaluate()</code> to access the element via JS directly</li>
</ul>
<p>When Playwright's auto locators don't work on open shadow DOM, you can use the <code>>></code> combinator — a Playwright-specific CSS syntax that pierces through open shadow DOM in one selector:</p>
<pre><code class="language-ts">await page.locator('my-button >> button').click();
</code></pre>
<p>This is equivalent to chaining locators but written as a single CSS string. Again, <strong>only works on open shadow DOM</strong>.</p>
</li>
<li><p>Demo: saucedemo.com doesn't use iframes or shadow DOM, so review the code examples above to understand the APIs. If your team's app uses iframes, apply <code>frameLocator</code> the same way shown here.</p>
</li>
</ul>
<hr>
<h2>Lesson 8 — Actions: Clicking, Typing, and More</h2>
<p><strong>Time</strong>: 35 min </p>
<p>Every action in Playwright auto-waits for the element to be visible, enabled, and stable before acting. You don't add manual waits.</p>
<ul>
<li><p><strong><code>click()</code></strong> — clicks an element:</p>
<pre><code class="language-html"><button>Add to Cart</button>
</code></pre>
<pre><code class="language-ts">await page.getByRole('button', { name: 'Add to Cart' }).click();
</code></pre>
</li>
<li><p><strong><code>click({ force: true })</code></strong> — bypass visibility/enabled checks (use sparingly):</p>
<pre><code class="language-ts">await page.getByRole('button', { name: 'Submit' }).click({ force: true });
</code></pre>
<p>Normally Playwright refuses to click hidden or disabled elements. <code>force: true</code> overrides that. Only use when you know what you're doing — e.g., a hidden file input or a button that becomes enabled via JS after some async load.</p>
</li>
<li><p><strong><code>fill()</code></strong> — clears an input field and types new text instantly (fastest, most reliable):</p>
<pre><code class="language-html"><input type="text" placeholder="Username" />
</code></pre>
<pre><code class="language-ts">await page.getByPlaceholder('Username').fill('standard_user');
</code></pre>
</li>
<li><p><strong><code>pressSequentially()</code></strong> — presses each key one at a time, like a human typing, with optional delay:</p>
<pre><code class="language-ts">await page.getByPlaceholder('Username').pressSequentially('standard_user', { delay: 100 });
</code></pre>
<p>Use it when:</p>
<ul>
<li>The app listens for individual key events (e.g., auto-complete, OTP inputs, search suggestions)</li>
<li>You need visual feedback for demos, videos, or slow-motion recordings</li>
<li>Otherwise prefer <code>fill()</code> — it's faster and more reliable</li>
</ul>
</li>
<li><p><strong><code>selectOption()</code></strong> — picks from a <code><select></code> dropdown:</p>
<pre><code class="language-html"><select id="sort">
<option value="az">Name (A to Z)</option>
<option value="za">Name (Z to A)</option>
</select>
</code></pre>
<pre><code class="language-ts">await page.getByLabel('Sort').selectOption('za');
// or by label text:
await page.getByLabel('Sort').selectOption('Name (Z to A)');
</code></pre>
</li>
<li><p><strong><code>check()</code> / <code>uncheck()</code></strong> — toggle checkboxes and radio buttons:</p>
<pre><code class="language-html"><input type="checkbox" id="terms" /> <label for="terms">I agree</label>
</code></pre>
<pre><code class="language-ts">await page.getByLabel('I agree').check();
await page.getByLabel('I agree').uncheck();
</code></pre>
</li>
<li><p><strong><code>page.keyboard.press()</code></strong> — simulate keyboard keys:</p>
<pre><code class="language-ts">await page.keyboard.press('Enter'); // press Enter
await page.keyboard.press('Tab'); // move focus
await page.keyboard.press('Control+a'); // select all
await page.keyboard.press('Escape'); // close modals
</code></pre>
</li>
<li><p><strong><code>page.mouse</code></strong> — direct mouse actions (rare — prefer <code>click()</code> on locators):</p>
<pre><code class="language-ts">await page.mouse.click(100, 200); // click at pixel coordinates
await page.mouse.dblclick(100, 200); // double-click
await page.mouse.wheel(0, 500); // scroll down 500px
</code></pre>
</li>
<li><p><strong>Demo</strong>: automate a login flow on saucedemo.com — <code>goto</code>, <code>fill</code> username, <code>fill</code> password, <code>click</code> login button. Then add a dropdown select for sorting products.</p>
</li>
</ul>
<hr>
<h2>Lesson 9 — Assertions & Auto-Waiting</h2>
<p><strong>Time</strong>: 45 min </p>
<ul>
<li><p><strong>Auto-waiting</strong> — Playwright commands don't act immediately. They wait for the element to be visible, enabled, and stable before proceeding. You never write <code>sleep(1000)</code> or <code>waitForElement()</code>.</p>
<pre><code class="language-ts">// No manual waits needed — Playwright waits until the element is ready:
await page.getByRole('button', { name: 'Login' }).click();
// If the button appears after 2 seconds, click() waits those 2 seconds automatically.
</code></pre>
</li>
<li><p><strong>Always use web-first assertions</strong> — assertions built into Playwright's <code>expect</code> that auto-retry and wait for the condition. Never use raw JavaScript checks for web testing.</p>
<p>❌ <strong>Without web-first</strong> (raw JS — fragile, no retry, bad errors):</p>
<pre><code class="language-ts">const text = await page.locator('.message').textContent();
expect(text).toBe('Success'); // fails if element hasn't rendered yet
const visible = await page.locator('.spinner').isVisible();
expect(visible).toBe(false); // fails if spinner is still showing
</code></pre>
<p>✅ <strong>With web-first</strong> (auto-retries, waits for condition, helpful error messages):</p>
<pre><code class="language-ts">await expect(page.getByTestId('message')).toHaveText('Success');
await expect(page.getByTestId('spinner')).toBeHidden();
</code></pre>
<p>Benefits of web-first assertions:</p>
<ul>
<li><strong>Auto-retry</strong> — they keep checking until the condition is met or timeout expires</li>
<li><strong>Readable errors</strong> — <code>"Expected element to be visible but was hidden"</code> instead of <code>"Expected true to be false"</code></li>
<li><strong>No manual <code>await</code> on locators</strong> — you pass the locator, not the resolved value</li>
<li><strong>Consistent timeout</strong> — respects the global timeout in config, no magic numbers</li>
</ul>
</li>
<li><p><strong>Auto-retrying assertions</strong> — <code>expect(locator)</code> doesn't check once. It retries until the assertion passes or the timeout expires.</p>
<pre><code class="language-ts">// This retries until the text appears or 5 seconds pass:
await expect(page.getByText('Thank you')).toBeVisible();
// Equivalent to: keep checking every 500ms for up to 5 seconds
</code></pre>
</li>
<li><p><strong>Common assertions</strong> with examples:</p>
<pre><code class="language-ts">// Text content
await expect(page.getByRole('heading')).toHaveText('Products');
await expect(page.getByTestId('cart-badge')).toContainText('3');
// Visibility
await expect(page.getByText('Error: Password is required')).toBeVisible();
await expect(page.getByRole('dialog')).toBeHidden();
// Form values
await expect(page.getByPlaceholder('Username')).toHaveValue('');
await expect(page.getByLabel('Email')).toHaveAttribute('type', 'email');
// Page-level
await expect(page).toHaveURL(/.*inventory\.html/); // regex match
await expect(page).toHaveTitle('Swag Labs');
</code></pre>
</li>
<li><p><strong>Negative assertions</strong> — check something is NOT present:</p>
<pre><code class="language-ts">await expect(page.getByText('Error')).not.toBeVisible();
await expect(page.locator('.spinner')).not.toBeVisible();
</code></pre>
</li>
<li><p><strong>Soft assertions</strong> — <code>expect.soft()</code> doesn't stop the test on failure. The test continues and reports all failures at the end:</p>
<pre><code class="language-ts">await expect.soft(page.getByRole('heading')).toHaveText('Products');
await expect.soft(page.getByTestId('cart-badge')).toContainText('3');
// If both fail, both are reported — test doesn't stop at the first one
</code></pre>
</li>
<li><p><strong>Custom timeout</strong> — override the default per assertion:</p>
<pre><code class="language-ts">await expect(page.getByText('Processing...')).toBeVisible({ timeout: 10000 });
</code></pre>
</li>
<li><p><strong>Capturing failures</strong> — configure Playwright to automatically capture screenshots, videos, and traces when a test fails. Add this to <code>playwright.config.ts</code>:</p>
<pre><code class="language-ts">export default defineConfig({
use: {
screenshot: 'only-on-failure', // PNG of the page
video: {
mode: 'retain-on-failure', // video recording at 1920x1080
size: { width: 1920, height: 1080 },
},
trace: 'retain-on-failure', // full debug trace on any failure
},
});
</code></pre>
<ul>
<li><code>screenshot: 'only-on-failure'</code> — saves a PNG of what the page looked like at the moment of failure</li>
<li><code>video</code> with <code>mode: 'retain-on-failure'</code> — records the full test run as a video, kept only if the test fails</li>
<li><code>trace: 'retain-on-failure'</code> — captures network requests, console logs, DOM snapshots, and timings (view with <code>npx playwright show-trace</code>)</li>
<li>No <code>retries</code> set locally — if a test fails, you see the failure immediately without waiting for a retry</li>
</ul>
</li>
<li><p><strong>CI-aware configuration</strong> — when running in CI (GitHub Actions, Jenkins, etc.), you want stricter behavior and different settings than local runs. Use <code>process.env.CI</code> to toggle:</p>
<pre><code class="language-ts">export default defineConfig({
forbidOnly: !!process.env.CI, // prevent `test.only` from being committed
retries: process.env.CI ? 2 : 0, // retry only in CI (flaky tests)
workers: process.env.CI ? 1 : undefined, // single worker in CI to reduce load
use: {
headless: process.env.CI ? true : false, // headed locally, headless in CI
screenshot: 'only-on-failure',
video: {
mode: 'retain-on-failure',
size: { width: 1920, height: 1080 },
},
trace: 'retain-on-failure',
},
});
</code></pre>
<ul>
<li><code>forbidOnly</code> — if someone accidentally commits a <code>test.only()</code>, the CI run fails immediately instead of running just that one test</li>
<li><code>workers: 1</code> — CI environments have limited resources; one worker prevents resource contention</li>
<li><code>headless: false</code> locally — you can watch the browser do its thing during development; CI forces headless because there's no display</li>
</ul>
</li>
<li><p><strong>Demo</strong>: add assertions to the login flow on saucedemo.com — check error text is visible on failed login, check heading text after successful login, then use <code>expect.soft</code> to collect multiple checks without stopping early</p>
</li>
</ul>
<hr>
<h2>Lesson 10 — Test Structure: Hooks & Organization</h2>
<p><strong>Time</strong>: 25 min </p>
<p>Playwright provides hooks to run code before/after tests, and a convention for organizing test files.</p>
<ul>
<li><p><strong>File organization</strong> — one file per feature, one <code>describe</code> per page/section, one <code>test</code> per scenario:</p>
<pre><code>tests/
non-bdd/
login.spec.ts # describe('Login') — all login scenarios
inventory.spec.ts # describe('Inventory') — all product listing scenarios
</code></pre>
</li>
<li><p><strong><code>test.describe()</code></strong> — groups related tests together. The output shows the group name as a heading.</p>
<pre><code class="language-ts">import { test, expect } from '@playwright/test';
test.describe('Login', () => {
test('successful login', async ({ page }) => {
await page.goto('https://www.saucedemo.com');
await page.locator('#user-name').fill('standard_user');
await page.locator('#password').fill('secret_sauce');
await page.locator('#login-button').click();
});
});
</code></pre>
</li>
<li><p><strong>Hooks</strong> — <code>beforeEach()</code>, <code>afterEach()</code>, <code>beforeAll()</code>, <code>afterAll()</code>. Useful for setup like clearing state or logging in, but keep tests explicit:</p>
<pre><code class="language-ts">test.afterEach(async ({ page }) => {
await page.evaluate(() => localStorage.clear());
});
</code></pre>
<p>In practice, tests often call <code>page.goto()</code> directly in each test. This keeps each test self-contained and easier to read — you don't have to look up what <code>beforeEach</code> does.</p>
</li>
<li><p><strong>Test data: variables over hardcoding</strong> — define data at the top so it's easy to change:</p>
<pre><code class="language-ts">const STANDARD_USER = 'standard_user';
const PASSWORD = 'secret_sauce';
test('successful login', async ({ page }) => {
await page.goto('https://www.saucedemo.com');
await page.locator('#user-name').fill(STANDARD_USER);
await page.locator('#password').fill(PASSWORD);
await page.locator('#login-button').click();
});
</code></pre>
</li>
<li><p><strong>Create a project for your tests</strong> — recall in Lesson 4 you removed Firefox and WebKit and kept only Chromium. Now we'll rename the project and point it to your test folder. Open <code>playwright.config.ts</code> and update the existing <code>projects</code> block:</p>
<pre><code class="language-ts">// Before:
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
// After:
projects: [
{
name: 'non-bdd-tests',
testDir: 'tests/non-bdd',
use: {
browserName: 'chromium',
viewport: { width: 1920, height: 1080 },
},
},
],
</code></pre>
<p>By defining projects with explicit names and paths, you control exactly which tests run. Your single test now runs once under the <code>non-bdd-tests</code> project.</p>
<p>Run only these tests: <code>npx playwright test --project non-bdd-tests</code></p>
</li>
<li><p><strong>Demo</strong>: create <code>tests/non-bdd/login.spec.ts</code> with 3 tests in a <code>describe('Login')</code> block — one successful login, one locked-out user, one failed login with empty credentials. Use variables for credentials, CSS selectors for locators.</p>
</li>
</ul>
<hr>
<h2>Lesson 11 — Page Object Model</h2>
<p><strong>Time</strong>: 60 min </p>
<p>This is where test automation gets professional. The Page Object Model (POM) is the most widely used design pattern in UI testing.</p>
<ul>
<li><p><strong>The problem</strong> — without POM, tests mix locators, actions, and assertions together. Every test repeats the same selectors. When the UI changes, you hunt through every file to update selectors.</p>
<p>❌ Messy test without POM:</p>
<pre><code class="language-ts">test('login then check inventory', async ({ page }) => {
await page.goto('https://www.saucedemo.com');
await page.locator('#user-name').fill('standard_user');
await page.locator('#password').fill('secret_sauce');
await page.locator('#login-button').click();
await expect(page.locator('.title')).toHaveText('Products');
});
</code></pre>
<p>Now imagine 50 tests like this. If the login button's <code>id</code> changes, you fix it 50 times.</p>
</li>
<li><p><strong>The solution</strong> — a Page Object class encapsulates everything about one page: its locators and the actions you can perform on it.</p>
<pre><code class="language-ts">// src/pages/BasePage.ts
import { Page } from '@playwright/test';
export class BasePage {
protected readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto(url: string) {
await this.page.goto(url);
}
}
</code></pre>
<pre><code class="language-ts">// src/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class LoginPage extends BasePage {
constructor(page: Page) {
super(page);
}
private get usernameInput(): Locator {
return this.page.locator('#user-name');
}
private get passwordInput(): Locator {
return this.page.locator('#password');
}
private get loginButton(): Locator {
return this.page.locator('#login-button');
}
private get errorMessage(): Locator {
return this.page.locator('[data-test="error"]');
}
async login(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
async getErrorMessage(): Promise<string> {
return (await this.errorMessage.textContent()) ?? '';
}
}
</code></pre>
</li>
<li><p><strong>Now the test is clean</strong> — one line to do what used to be five:</p>
<pre><code class="language-ts">const loginPage = new LoginPage(page);
await loginPage.goto('https://www.saucedemo.com');
await loginPage.login('standard_user', 'secret_sauce');
const error = await loginPage.getErrorMessage();
</code></pre>
</li>
<li><p><strong>Access modifiers on getter methods</strong> — locator getters are <code>private</code> because tests shouldn't reach into the page object and manipulate elements directly. Instead, tests use the page's public methods (<code>login()</code>, <code>getErrorMessage()</code>). This enforces encapsulation: the page is the single source of truth for how to interact with the UI. If a selector changes, you update one file — no test needs to know.</p>
<ul>
<li><p><code>private</code> — locator used only within the page (most locators). Tests interact via public methods.</p>
</li>
<li><p><code>public</code> — locator you intentionally expose for tests to use when no wrapper method exists.</p>
</li>
<li><p><code>protected</code> — locator shared among related pages via inheritance (rare in POM).</p>
<table>
<thead>
<tr>
<th>Modifier</th>
<th align="center">Accessible in class</th>
<th align="center">Accessible in subclass</th>
<th align="center">Accessible from test</th>
</tr>
</thead>
<tbody><tr>
<td><code>private</code></td>
<td align="center">✅</td>
<td align="center">❌</td>
<td align="center">❌</td>
</tr>
<tr>
<td><code>protected</code></td>
<td align="center">✅</td>
<td align="center">✅</td>
<td align="center">❌</td>
</tr>
<tr>
<td><code>public</code></td>
<td align="center">✅</td>
<td align="center">✅</td>
<td align="center">✅</td>
</tr>
</tbody></table>
</li>
</ul>
</li>
<li><p><strong>The InventoryPage</strong> — after login, you land on the inventory page. Create a page object for it too:</p>
<pre><code class="language-ts">// src/pages/InventoryPage.ts
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class InventoryPage extends BasePage {
constructor(page: Page) {