├── Hands-On Web UI Testing Slides.pdf ├── tests └── test_fw.py ├── Pipfile ├── LICENSE ├── .gitignore ├── Pipfile.lock └── README.md /Hands-On Web UI Testing Slides.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AndyLPK247/djangocon-2019-web-ui-testing/HEAD/Hands-On Web UI Testing Slides.pdf -------------------------------------------------------------------------------- /tests/test_fw.py: -------------------------------------------------------------------------------- 1 | """ 2 | Run these tests after completing the setup steps to verify that the framework works. 3 | """ 4 | 5 | def test_the_tests(): 6 | assert True 7 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | pytest = "*" 10 | 11 | [requires] 12 | python_version = "3.7" 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Andrew Knight 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # IDEs 107 | .vscode 108 | .idea 109 | *.iml 110 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "828b8ad012f4c8773e6e61e3ac2be0ffcd7540fd7ed175a8355676c8e31c4d3d" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "atomicwrites": { 20 | "hashes": [ 21 | "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", 22 | "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" 23 | ], 24 | "version": "==1.3.0" 25 | }, 26 | "attrs": { 27 | "hashes": [ 28 | "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", 29 | "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" 30 | ], 31 | "version": "==19.1.0" 32 | }, 33 | "importlib-metadata": { 34 | "hashes": [ 35 | "sha256:23d3d873e008a513952355379d93cbcab874c58f4f034ff657c7a87422fa64e8", 36 | "sha256:80d2de76188eabfbfcf27e6a37342c2827801e59c4cc14b0371c56fed43820e3" 37 | ], 38 | "markers": "python_version < '3.8'", 39 | "version": "==0.19" 40 | }, 41 | "more-itertools": { 42 | "hashes": [ 43 | "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", 44 | "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4" 45 | ], 46 | "version": "==7.2.0" 47 | }, 48 | "packaging": { 49 | "hashes": [ 50 | "sha256:a7ac867b97fdc07ee80a8058fe4435ccd274ecc3b0ed61d852d7d53055528cf9", 51 | "sha256:c491ca87294da7cc01902edbe30a5bc6c4c28172b5138ab4e4aa1b9d7bfaeafe" 52 | ], 53 | "version": "==19.1" 54 | }, 55 | "pluggy": { 56 | "hashes": [ 57 | "sha256:0825a152ac059776623854c1543d65a4ad408eb3d33ee114dff91e57ec6ae6fc", 58 | "sha256:b9817417e95936bf75d85d3f8767f7df6cdde751fc40aed3bb3074cbcb77757c" 59 | ], 60 | "version": "==0.12.0" 61 | }, 62 | "py": { 63 | "hashes": [ 64 | "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", 65 | "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" 66 | ], 67 | "version": "==1.8.0" 68 | }, 69 | "pyparsing": { 70 | "hashes": [ 71 | "sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80", 72 | "sha256:d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4" 73 | ], 74 | "version": "==2.4.2" 75 | }, 76 | "pytest": { 77 | "hashes": [ 78 | "sha256:95b1f6db806e5b1b5b443efeb58984c24945508f93a866c1719e1a507a957d7c", 79 | "sha256:c3d5020755f70c82eceda3feaf556af9a341334414a8eca521a18f463bcead88" 80 | ], 81 | "index": "pypi", 82 | "version": "==5.1.1" 83 | }, 84 | "six": { 85 | "hashes": [ 86 | "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", 87 | "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" 88 | ], 89 | "version": "==1.12.0" 90 | }, 91 | "wcwidth": { 92 | "hashes": [ 93 | "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", 94 | "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c" 95 | ], 96 | "version": "==0.1.7" 97 | }, 98 | "zipp": { 99 | "hashes": [ 100 | "sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e", 101 | "sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335" 102 | ], 103 | "version": "==0.6.0" 104 | } 105 | }, 106 | "develop": {} 107 | } 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # djangocon-2019-web-ui-testing 2 | This repository contains the companion project for the 3 | [Hands-On Web UI Testing](https://2019.djangocon.us/tutorials/hands-on-web-ui-testing/) tutorial 4 | taught by [Pandy Knight](https://twitter.com/AutomationPanda) 5 | at [DjangoCon 2019](https://2019.djangocon.us/). 6 | If you are taking the tutorial, 7 | then please clone this repository and follow the instructions below. 8 | The slide deck for the tutorial is also in this repository. 9 | 10 | ## WARNING! 11 | 12 | **This project is archived.** 13 | It was the example code for a one-time workshop, and it will not be actively maintained. 14 | If you want to learn how to do Web UI testing with Python and Selenium WebDriver, 15 | then please take [Selenium WebDriver with Python](https://testautomationu.applitools.com/selenium-webdriver-python-tutorial/) 16 | from [Test Automation University](https://testautomationu.applitools.com/). 17 | It is a free course that covers the same material as this tutorial. 18 | 19 | ## Python Setup 20 | 21 | You can complete this tutorial using any OS: Windows, macOS, Linux, etc. 22 | 23 | This tutorial requires Python 3.7 or higher. 24 | You can download the latest Python version from [Python.org](https://www.python.org/downloads/). 25 | 26 | This tutorial also requires [pipenv](https://docs.pipenv.org/). 27 | To install pipenv, run `pip install pipenv` from the command line. 28 | 29 | You should also have a Python editor/IDE of your choice. 30 | Good choices include [PyCharm](https://www.jetbrains.com/pycharm/) 31 | and [Visual Studio Code](https://code.visualstudio.com/docs/languages/python). 32 | 33 | You will also need [Git](https://git-scm.com/) to copy this project code. 34 | If you are new to Git, [try learning the basics](https://try.github.io/). 35 | 36 | ## WebDriver Setup 37 | 38 | For Web UI testing, you will need to install the latest versions of 39 | [Google Chrome](https://www.google.com/chrome/) 40 | and [Mozilla Firefox](https://www.mozilla.org/en-US/firefox/). 41 | You can use other browsers with Selenium WebDriver, but the tutorial will use Chrome and Firefox. 42 | 43 | You will also need to install the latest versions of the WebDriver executables for these browsers: [ChromeDriver](https://sites.google.com/a/chromium.org/chromedriver/) for Chrome 44 | and [geckodriver](https://github.com/mozilla/geckodriver/releases) for Firefox. 45 | Each test case will launch the WebDriver executable for its target browser. 46 | The WebDriver executable will act as a proxy between the test automation and the browser instance. 47 | Please use the latest versions of both the browsers and the WebDriver executables. 48 | Older versions might be incompatible with each other. 49 | 50 | ChromeDriver and geckodriver must be installed on the 51 | [system path](https://en.wikipedia.org/wiki/PATH_(variable)). 52 | 53 | To install them on Windows: 54 | 55 | 1. Create a folder named `C:\Selenium`. 56 | 2. Move the executables into this folder. 57 | 3. Add this folder to the *Path* environment variable. (See [How to Add to Windows PATH Environment Variable](https://helpdeskgeek.com/windows-10/add-windows-path-environment-variable/).) 58 | 59 | To install them on Linux, macOS, and other UNIX variants, 60 | simply move them to the `/usr/local/bin/` directory: 61 | 62 | ```bash 63 | $ mv /path/to/ChromeDriver /usr/local/bin 64 | $ mv /path/to/geckodriver /usr/local/bin 65 | ``` 66 | 67 | This directory should already be included in the system path. 68 | For troubleshooting, see: 69 | 70 | * [Setting the path on macOS](https://www.cyberciti.biz/faq/appleosx-bash-unix-change-set-path-environment-variable/) 71 | * [Setting the path on Linux](https://stackoverflow.com/questions/14637979/how-to-permanently-set-path-on-linux-unix) 72 | 73 | To verify correct setup on any operating system, simply try to run them from the terminal: 74 | 75 | ```bash 76 | $ ChromeDriver 77 | $ geckodriver 78 | ``` 79 | 80 | You may or may not see any output. 81 | Just verify that you can run them without errors. 82 | Use Ctrl-C to kill them. 83 | 84 | ## Project Setup 85 | 86 | 1. Clone this repository. 87 | 2. Run `cd djangocon-2019-web-ui-testing` to enter the project. 88 | 3. Run `pipenv install` to install the dependencies. 89 | 4. Run `pipenv run python -m pytest` to verify that the framework can run tests. 90 | 5. Create a branch for your code changes. (See *Repository Branching* below.) 91 | 92 | ## Repository Branching 93 | 94 | The `master` branch contains the code for the tutorial's starting point. 95 | The project is basically empty in the `master` branch. 96 | 97 | If you want to code along with the tutorial, then create a branch for your work off the `master` branch. 98 | To create your own branch named `tutorial/develop`, run: 99 | 100 | > git checkout master 101 | > git branch tutorial/develop 102 | > git checkout tutorial/develop 103 | 104 | The `example/*` branches contain the completed code for tutorial parts. 105 | If you get stuck, you can always check the example code. 106 | 107 | * `example/1-first-test` 108 | * `example/2-webdriver-setup` 109 | * `example/3-page-objects` 110 | * `example/4-locators` 111 | * `example/5-webdriver-calls` 112 | * `example/6-browser-config` 113 | * `example/7-race-conditions` 114 | * `example/8-parallel-testing` 115 | * `example/develop` (main development branch for the examples) 116 | 117 | ## Tutorial Instructions 118 | 119 | ### Part 1: Writing Our First Test 120 | 121 | *Time Estimate: 5 Minutes* 122 | 123 | *Example Branch: example/1-first-test* 124 | 125 | We should always write test *cases* before writing any test *code*. 126 | Test cases are procedures that exercise behavior to verify goodness and identify badness. 127 | Test code simply automates test cases. 128 | Writing a test case first helps us form our thoughts well. 129 | 130 | Consider the following test case: 131 | 132 | ```gherkin 133 | Scenario: Basic DuckDuckGo Search 134 | Given the DuckDuckGo home page is displayed 135 | When the user searches for “panda” 136 | Then the search result title contains “panda” 137 | And the search result query is “panda” 138 | And the search result links pertain to “panda” 139 | ``` 140 | 141 | Let's implement this test using pytest. 142 | Create a new file named `test_search.py` under the `tests` directory, 143 | and add the following code: 144 | 145 | ```python 146 | """ 147 | These tests cover DuckDuckGo searches. 148 | """ 149 | 150 | def test_basic_duckduckgo_search(): 151 | 152 | # Given the DuckDuckGo home page is displayed 153 | # TODO 154 | 155 | # When the user searches for "panda" 156 | # TODO 157 | 158 | # Then the search result title contains "panda" 159 | # TODO 160 | 161 | # And the search result query is "panda" 162 | # TODO 163 | 164 | # And the search result links pertain to "panda" 165 | # TODO 166 | 167 | raise Exception("Incomplete Test") 168 | ``` 169 | 170 | Adding comments to stub each step may seem trivial, 171 | but it's a good first step when writing new test cases. 172 | We can simply add code at each TODO line as we automate. 173 | Once we finish writing the test's code, we will remove the exception at the end. 174 | Also, note that pytest expects all test functions to begin with `test_`. 175 | 176 | To avoid confusion when we run tests, let's remove the old placeholder test. 177 | Delete `tests/test_fw.py`. 178 | 179 | Rerun the tests using `pipenv run python -m pytest`. 180 | The `test_basic_duckduckgo_search` should be the only test that runs, 181 | and it should fail due to the "Incomplete Test" exception. 182 | 183 | Finally, commit your code change. Part 1 is complete! 184 | 185 | ### Part 2: Setting Up Selenium WebDriver 186 | 187 | *Time Estimate: 5 Minutes* 188 | 189 | *Example Branch: example/2-webdriver-setup* 190 | 191 | [Selenium WebDriver](https://www.seleniumhq.org/projects/webdriver/) 192 | is a tool for automating Web UI interactions with live browsers. 193 | It works with several popular programming languages and browser types. 194 | 195 | The Selenium WebDriver package for Python is named `selenium`. 196 | Run `pipenv install selenium` to install it for our project. 197 | 198 | Every test should use its own WebDriver instance. 199 | This keeps things simple and safe. 200 | The best way to set up the WebDriver instance is to use a 201 | [pytest fixture](https://docs.pytest.org/en/latest/fixture.html). 202 | Fixtures are basically setup and cleanup functions. 203 | As a best practice, they should be placed in a `conftest.py` module so they can be used by any test. 204 | 205 | Create a new file named `tests/conftest.py` and add the following code: 206 | 207 | ```python 208 | """ 209 | This module contains shared fixtures. 210 | """ 211 | 212 | import pytest 213 | import selenium.webdriver 214 | 215 | 216 | @pytest.fixture 217 | def browser(): 218 | 219 | # Initialize the ChromeDriver instance 220 | b = selenium.webdriver.Chrome() 221 | 222 | # Make its calls wait up to 10 seconds for elements to appear 223 | b.implicitly_wait(10) 224 | 225 | # Return the WebDriver instance for the setup 226 | yield b 227 | 228 | # Quit the WebDriver instance for the cleanup 229 | b.quit() 230 | ``` 231 | 232 | The `browser` fixture uses Chrome. 233 | Other browser types could be used instead. 234 | Real-world projects often read browser choice from a config file here. 235 | 236 | The implicit wait will make sure WebDriver calls wait for elements to appear before sending calls to them. 237 | 10 seconds should be reasonable for this test project's needs. 238 | For larger projects, however, setting explicit waits is a better practice 239 | because different calls need different wait times. 240 | Read more about implicit versus explicit waits [here](https://selenium-python.readthedocs.io/waits.html). 241 | 242 | The `yield` statement makes the `browser` fixture a generator. 243 | The first iteration will do the "setup" steps, 244 | while the second iteration will do the "cleanup" steps. 245 | Each test must make sure to *quit* the WebDriver instance as part of cleanup, 246 | or else zombie processes might lock system resources! 247 | 248 | Now, update `test_basic_duckduckgo_search` in `tests/test_search.py` to call the new fixture: 249 | 250 | ```python 251 | def test_basic_duckduckgo_search(browser): 252 | # ... 253 | ``` 254 | 255 | Whenever a pytest test function declares a fixture by name as an argument, 256 | pytest will automatically call that fixture before the test runs. 257 | Whatever the fixture returns will be passed into the test function. 258 | Therefore, we can access the WebDriver instance using the `browser` variable! 259 | 260 | Rerun the test using `pipenv run python -m pytest` to test the fixture. 261 | Even though the test should still fail, 262 | Chrome should briefly pop up for a few seconds while the test is running. 263 | Make sure Chrome quits once the test is done. 264 | Then, commit your latest code changes. 265 | Part 2 is now complete! 266 | 267 | ### Part 3: Defining Page Objects 268 | 269 | *Time Estimate: 10 Minutes* 270 | 271 | *Example Branch: example/3-page-objects* 272 | 273 | A **page object** is an object representing a Web page or component. 274 | They have *locators* for finding elements, 275 | as well as *interaction methods* that interact with the page under test. 276 | Page objects make low-level Selenium WebDriver calls 277 | so that tests can make short, readable calls instead of complex ones. 278 | 279 | Since we have our test steps, we know what pages and elements our test needs. 280 | There are two pages under test, each with a few interactions: 281 | 282 | 1. The DuckDuckGo search page 283 | * Load the page 284 | * Search a phrase 285 | 2. The DuckDuckGo results page 286 | * Get the result link titles 287 | * Get the search query 288 | * Get the title 289 | 290 | Let's write stubs for our page object classes. 291 | Each interaction should have its own method. 292 | Later, we can implement the interaction methods with Selenium WebDriver calls. 293 | Create a new Python package named `pages`. 294 | To do this create a directory under the root directory named `pages`. 295 | Then, put a blank file in it named `__init__.py`. 296 | The `pages` directory should *not* be under the `tests` directory. 297 | Why? When using pytest, the `tests` folder should *not* be a package. 298 | 299 | Create a new module named `pages/search.py` and add the following code 300 | for the DuckDuckGo search page: 301 | 302 | ```python 303 | """ 304 | This module contains DuckDuckGoSearchPage, 305 | the page object for the DuckDuckGo search page. 306 | """ 307 | 308 | 309 | class DuckDuckGoSearchPage: 310 | 311 | def __init__(self, browser): 312 | self.browser = browser 313 | 314 | def load(self): 315 | # TODO 316 | pass 317 | 318 | def search(self, phrase): 319 | # TODO 320 | pass 321 | ``` 322 | 323 | Create another new module named `pages/result.py` and add the following code 324 | for the DuckDuckGo result page: 325 | 326 | ```python 327 | """ 328 | This module contains DuckDuckGoResultPage, 329 | the page object for the DuckDuckGo search result page. 330 | """ 331 | 332 | 333 | class DuckDuckGoResultPage: 334 | 335 | def __init__(self, browser): 336 | self.browser = browser 337 | 338 | def result_link_titles(self): 339 | # TODO 340 | return [] 341 | 342 | def search_input_value(self): 343 | # TODO 344 | return "" 345 | 346 | def title(self): 347 | # TODO 348 | return "" 349 | ``` 350 | 351 | Every page object needs a reference to the WebDriver instance. 352 | That's why the `__init__` methods take in and store a reference to `browser`. 353 | 354 | Finally, update `test_basic_duckduckgo_search` in `tests/test_search.py` 355 | with the following code: 356 | 357 | ```python 358 | """ 359 | These tests cover DuckDuckGo searches. 360 | """ 361 | 362 | from pages.result import DuckDuckGoResultPage 363 | from pages.search import DuckDuckGoSearchPage 364 | 365 | 366 | def test_basic_duckduckgo_search(browser): 367 | search_page = DuckDuckGoSearchPage(browser) 368 | result_page = DuckDuckGoResultPage(browser) 369 | PHRASE = "panda" 370 | 371 | # Given the DuckDuckGo home page is displayed 372 | search_page.load() 373 | 374 | # When the user searches for "panda" 375 | search_page.search(PHRASE) 376 | 377 | # Then the search result title contains "panda" 378 | assert PHRASE in result_page.title() 379 | 380 | # And the search result query is "panda" 381 | assert PHRASE == result_page.search_input_value() 382 | 383 | # And the search result links pertain to "panda" 384 | for title in result_page.result_link_titles(): 385 | assert PHRASE.lower() in title.lower() 386 | 387 | # TODO: Remove this exception once the test is complete 388 | raise Exception("Incomplete Test") 389 | ``` 390 | 391 | Notice how we are able to write all the test steps using page object calls and assertions. 392 | We also kept the step comments so the code is well-documented. 393 | Even though we haven't made any Selenium WebDriver calls, our test case function is nearly complete! 394 | Our code is readable and understandable. 395 | It delivers clear testing value. 396 | 397 | Rerun the test using `pipenv run python -m pytest`. 398 | The test should fail again, but this time, it should fail on one of the assertions. 399 | Then, commit your latest code changes. Part 3 is now complete! 400 | 401 | ### Part 4: Finding Locators for Elements 402 | 403 | *Time Estimate: 15 Minutes* 404 | 405 | *Example Branch: example/4-locators* 406 | 407 | An *element* is a "thing" on a Web page. 408 | Browsers render elements such as buttons, dropdowns, and input fields using the page's HTML code. 409 | Users interact directly with the page's elements. 410 | Tests use page objects to interact with elements like a user. 411 | 412 | Interactions typically require three steps: 413 | 414 | 1. Wait for the target element to exist 415 | 2. Get an object representing the target element 416 | 3. Send commands to the element object 417 | 418 | In our solution, waiting is handled automatically thanks to the browser fixture's `implicitly_wait` call. 419 | Getting the element object, however, requires a locator. 420 | 421 | *Locators* are query strings that use HTML attributes to find elements on a Web page. 422 | There are many types of locators: 423 | 424 | * ID 425 | * Name 426 | * Class name 427 | * CSS Selector 428 | * XPath 429 | * Link text 430 | * Partial link text 431 | * Tag name 432 | 433 | For example, if the page has the following element: 434 | 435 | ```html 436 | 437 | ``` 438 | 439 | Then, a page object could use an ID locator for "django_ok" to get this element. 440 | 441 | Locators are not element objects themselves but instead point to elements. 442 | The WebDriver instance uses locators to fetch and construct element objects. 443 | Why are locators and elements separate concerns? 444 | Elements on a page are always changing: 445 | they may take time to load, or they may change with user interaction. 446 | Locators, however, are always the same: 447 | they simply specify how to get elements. 448 | For example, a locator could be used to prove that an element does *not* exist. 449 | 450 | For our test, we need locators for three elements: 451 | 452 | 1. The search input on the DuckDuckGo search page 453 | 2. The search input on the DuckDuckGo results page 454 | 3. The result links on the DuckDuckGo results page 455 | 456 | (Note: The page title is not a Web element. It can be fetched as a `browser` property.) 457 | 458 | Writing good locators is a bit of an art. 459 | Inspecting the HTML source of a live page makes it easy. 460 | To do this, open the [DuckDuckGo search page](https://duckduckgo.com/) in Chrome. 461 | Then, right-click the page and select "Inspect". 462 | [Chrome DevTools](https://developers.google.com/web/tools/chrome-devtools/) will open. 463 | The "Elements" tab shows the HTML source. 464 | As you move the cursor over HTML elements in the source, 465 | Chrome will highlight the elements on the page. 466 | Now, click the icon with the box and cursor in the upper-left corner of the DevTools pane. 467 | Move the cursor over elements on the page, and you will see them highlighted in the source. 468 | Neat! 469 | 470 | Try to find the search input element. 471 | Its HTML should look like this: 472 | 473 | ```html 474 | 484 | ``` 485 | 486 | Notice that there is an `id` attribute set to "search_form_input_homepage". 487 | Let's use this as our locator and update `pages/search.py`. 488 | 489 | First, import `By` from the `selenium` package so we can write locators: 490 | 491 | ```python 492 | from selenium.webdriver.common.by import By 493 | ``` 494 | 495 | Then, add the following attribute to the `DuckDuckGoSearchPage` class: 496 | 497 | ```python 498 | SEARCH_INPUT = (By.ID, 'search_form_input_homepage') 499 | ``` 500 | 501 | `By` contains property keys for each type of locator. 502 | We can write locators as tuples of the locator type and the query string. 503 | (We will use this locator for interaction calls in the next part of the tutorial.) 504 | 505 | The full code for `pages/search.py` should now look like this: 506 | 507 | ```python 508 | """ 509 | This module contains DuckDuckGoSearchPage, 510 | the page object for the DuckDuckGo search page. 511 | """ 512 | 513 | from selenium.webdriver.common.by import By 514 | 515 | 516 | class DuckDuckGoSearchPage: 517 | 518 | SEARCH_INPUT = (By.ID, 'search_form_input_homepage') 519 | 520 | def __init__(self, browser): 521 | self.browser = browser 522 | 523 | def load(self): 524 | # TODO 525 | pass 526 | 527 | def search(self, phrase): 528 | # TODO 529 | pass 530 | ``` 531 | 532 | Let's write locators for the `DuckDuckGoResultPage` next. 533 | Perform a search, inspect the page, and try to come up with locators on your own. 534 | 535 | Below is the code for `pages/result.py` with locators: 536 | 537 | ```python 538 | """ 539 | This module contains DuckDuckGoResultPage, 540 | the page object for the DuckDuckGo search result page. 541 | """ 542 | 543 | from selenium.webdriver.common.by import By 544 | 545 | 546 | class DuckDuckGoResultPage: 547 | 548 | RESULT_LINKS = (By.CSS_SELECTOR, 'a.result__a') 549 | SEARCH_INPUT = (By.ID, 'search_form_input') 550 | 551 | def __init__(self, browser): 552 | self.browser = browser 553 | 554 | def result_link_titles(self): 555 | # TODO 556 | return [] 557 | 558 | def search_input_value(self): 559 | # TODO 560 | return "" 561 | 562 | def title(self): 563 | # TODO 564 | return "" 565 | ``` 566 | 567 | Thankfully, the search input element on the result page 568 | is similar to the one on the search page. 569 | 570 | The locator for the result links is a bit trickier, though. 571 | It must find all result elements that contain the search phrase in their display texts. 572 | This locator will return a list of elements, not just one. 573 | The result links are all "a" hyperlink elements with a class named "result__a". 574 | We can use a CSS selector for its query. 575 | 576 | We should always try to use the simplest locator that uniquely finds the target elements. 577 | IDs, names, and class names are the easiest, 578 | but sometimes, we must use CSS selectors and XPaths. 579 | To learn more about writing good locators, 580 | take the [Web Element Locator Strategies](https://testautomationu.applitools.com/web-element-locator-strategies/) course 581 | from [Test Automation University](https://testautomationu.applitools.com/). 582 | 583 | Although the test will still fail, 584 | rerun it using `pipenv run python -m pytest` to make sure our changes did no harm. 585 | Then, commit your latest code changes. Part 4 is now complete! 586 | 587 | ### Part 5: Making WebDriver Calls 588 | 589 | *Time Estimate: 15 Minutes* 590 | 591 | *Example Branch: example/5-webdriver-calls* 592 | 593 | Now we can implement all the page object methods using WebDriver calls. 594 | The [WebDriver API for Python](https://selenium-python.readthedocs.io/api.html) 595 | documents all WebDriver calls. 596 | If you aren't sure how to do something, look it up. 597 | WebDriver can do anything a user can do on a Web page! 598 | 599 | Let's start with `DuckDuckGoSearchPage`. 600 | The `load` method is a one-line WebDriver call, 601 | but it's good practice to make the URL a class variable: 602 | 603 | ```python 604 | URL = 'https://www.duckduckgo.com' 605 | 606 | def load(self): 607 | self.browser.get(self.URL) 608 | ``` 609 | 610 | The `search` method is a bit more complex because it interacts with an element. 611 | We need to use a *locator* to find the search input element, 612 | and then we need to *send keys* to type the search phrase into the element. 613 | 614 | First, update the `selenium` package imports: 615 | 616 | ```python 617 | from selenium.webdriver.common.by import By 618 | from selenium.webdriver.common.keys import Keys 619 | ``` 620 | 621 | The `search` method needs two parts: finding the element and sending the keystrokes. 622 | Thankfully, we already have the locator for the element. 623 | 624 | ```python 625 | def search(self, phrase): 626 | search_input = self.browser.find_element(*self.SEARCH_INPUT) 627 | search_input.send_keys(phrase + Keys.RETURN) 628 | ``` 629 | 630 | The `find_element` method will return the first element found by the locator. 631 | Notice how the locator uses the `*` operator to expand the SEARCH_INPUT locator tuple into arguments. 632 | The `selenium` package offers specific locator type methods (like `find_element_by_name`), 633 | but using the generic `find_element` method with argument expansion is better practice. 634 | If the locator type must be changed due to Web page updates, 635 | then the `find_element` call would not need to be changed. 636 | 637 | The `send_keys` method sends the search phrase passed into the `search` method. 638 | This means that the page object can search any phrase! 639 | The addition of `Keys.RETURN` will send the ENTER/RETURN key as well, 640 | which will submit the input value to perform the search and load the results page. 641 | 642 | The full code for `pages/search.py` should look like this: 643 | 644 | ```python 645 | """ 646 | This module contains DuckDuckGoSearchPage, 647 | the page object for the DuckDuckGo search page. 648 | """ 649 | 650 | from selenium.webdriver.common.by import By 651 | from selenium.webdriver.common.keys import Keys 652 | 653 | 654 | class DuckDuckGoSearchPage: 655 | 656 | # URL 657 | 658 | URL = 'https://www.duckduckgo.com' 659 | 660 | # Locators 661 | 662 | SEARCH_INPUT = (By.ID, 'search_form_input_homepage') 663 | 664 | # Initializer 665 | 666 | def __init__(self, browser): 667 | self.browser = browser 668 | 669 | # Interaction Methods 670 | 671 | def load(self): 672 | self.browser.get(self.URL) 673 | 674 | def search(self, phrase): 675 | search_input = self.browser.find_element(*self.SEARCH_INPUT) 676 | search_input.send_keys(phrase + Keys.RETURN) 677 | ``` 678 | 679 | Now, let's do `DuckDuckGoResultPage`. 680 | The `title` method is the easiest one because it just returns a property value: 681 | 682 | ```python 683 | def title(self): 684 | return self.browser.title 685 | ``` 686 | 687 | The `search_input_value` method is similar to the `search` method from `DuckDuckGoSearchPage`, 688 | but instead of sending a command, it asks for state from the page. 689 | The "value" attribute contains the text a user types into an "input" element. 690 | 691 | ```python 692 | def search_input_value(self): 693 | search_input = self.browser.find_element(*self.SEARCH_INPUT) 694 | value = search_input.get_attribute('value') 695 | return value 696 | ``` 697 | 698 | The `result_link_titles` method is a bit more complex. 699 | The test must verify that the result page displays links relating to the search phrase. 700 | This method should find all result links on the page. 701 | Then, it should get the titles for those result links. 702 | Remember, the test asserts that the search phrase is in each title. 703 | This assertion may seem too stringent because it could fail the test for possibly valid links, 704 | but it should be good enough for simple search terms. 705 | (Again, remember, the test is merely a basic search test.) 706 | 707 | The `result_link_titles` method should look like this: 708 | 709 | ```python 710 | def result_link_titles(self): 711 | links = self.browser.find_elements(*self.RESULT_LINKS) 712 | titles = [link.text for link in links] 713 | return titles 714 | ``` 715 | 716 | Notice that it uses `find_elements` (plural) to get a list of matching elements. 717 | 718 | The full code for `pages/result.py` should look like this: 719 | 720 | ```python 721 | """ 722 | This module contains DuckDuckGoResultPage, 723 | the page object for the DuckDuckGo search result page. 724 | """ 725 | 726 | from selenium.webdriver.common.by import By 727 | 728 | 729 | class DuckDuckGoResultPage: 730 | 731 | # Locators 732 | 733 | RESULT_LINKS = (By.CSS_SELECTOR, 'a.result__a') 734 | SEARCH_INPUT = (By.ID, 'search_form_input') 735 | 736 | # Initializer 737 | 738 | def __init__(self, browser): 739 | self.browser = browser 740 | 741 | # Interaction Methods 742 | 743 | def result_link_titles(self): 744 | links = self.browser.find_elements(*self.RESULT_LINKS) 745 | titles = [link.text for link in links] 746 | return titles 747 | 748 | def search_input_value(self): 749 | search_input = self.browser.find_element(*self.SEARCH_INPUT) 750 | value = search_input.get_attribute('value') 751 | return value 752 | 753 | def title(self): 754 | return self.browser.title 755 | ``` 756 | 757 | Finally, remove the "incomplete" exception from `tests/test_search.py`. 758 | That module's code should look like this: 759 | 760 | ```python 761 | """ 762 | These tests cover DuckDuckGo searches. 763 | """ 764 | 765 | from pages.result import DuckDuckGoResultPage 766 | from pages.search import DuckDuckGoSearchPage 767 | 768 | 769 | def test_basic_duckduckgo_search(browser): 770 | search_page = DuckDuckGoSearchPage(browser) 771 | result_page = DuckDuckGoResultPage(browser) 772 | PHRASE = "panda" 773 | 774 | # Given the DuckDuckGo home page is displayed 775 | search_page.load() 776 | 777 | # When the user searches for "panda" 778 | search_page.search(PHRASE) 779 | 780 | # Then the search result title contains "panda" 781 | assert PHRASE in result_page.title() 782 | 783 | # And the search result query is "panda" 784 | assert PHRASE == result_page.search_input_value() 785 | 786 | # And the search result links pertain to "panda" 787 | for title in result_page.result_link_titles(): 788 | assert PHRASE.lower() in title.lower() 789 | ``` 790 | 791 | Rerun the test using `pipenv run python -m pytest`. 792 | Now, finally, it should run to completion and pass! 793 | The test will take a few second to run because it must wait for page loads. 794 | Chrome should pop up and automatically go through all test steps. 795 | Try not to interfere with the browser as the test runs. 796 | Make sure pytest doesn't report any failures when it completes. 797 | 798 | ### Part 6: Configuring Multiple Browsers 799 | 800 | *Time Estimate: 15 Minutes* 801 | 802 | *Example Branch: example/6-browser-config* 803 | 804 | Our test currently runs on Chrome, 805 | but it should be able to run on other browsers, too. 806 | Any Web UI test should be configurable to run on any applicable browser. 807 | Let's run it on Headless Chrome and Firefox! 808 | 809 | Browser choice is an aspect of testing. 810 | In theory, every test should run on every supported browser. 811 | Thus, browser choice should be treated as an input for test automation. 812 | It should not be hard-coded into automation code. 813 | It should also not be written as pytest parameters. 814 | One test session should use one browser. 815 | If another browser needs to be tested, then launch another test session. 816 | This design keeps test code and test executions simpler. 817 | 818 | Create a new file named `config.json` in the project's root directory. 819 | JSON files are very easy to use in Python. 820 | The `json` module is part of the standard library, 821 | and JSON files can be parsed into dictionaries with one line. 822 | Add the following lines: 823 | 824 | ```json 825 | { 826 | "browser": "Chrome", 827 | "implicit_wait": 10 828 | } 829 | ``` 830 | 831 | Notice how these inputs correspond to values in the `browser` fixture. 832 | Then, add a new fixture to `tests/conftest.py`: 833 | 834 | ```python 835 | import json 836 | 837 | @pytest.fixture 838 | def config(scope='session'): 839 | 840 | # Read the file 841 | with open('config.json') as config_file: 842 | config = json.load(config_file) 843 | 844 | # Assert values are acceptable 845 | assert config['browser'] in ['Firefox', 'Chrome', 'Headless Chrome'] 846 | assert isinstance(config['implicit_wait'], int) 847 | assert config['implicit_wait'] > 0 848 | 849 | # Return config so it can be used 850 | return config 851 | ``` 852 | 853 | This fixture reads the `config.json` file. 854 | It also validates the inputs so that tests won't run if the inputs are bad. 855 | The fixture's *scope* is set to "session" so that the fixture is called only one time for all tests. 856 | There is no need to read it repeatedly for every test. 857 | 858 | Update the `browser` fixture to use these inputs: 859 | 860 | ```python 861 | @pytest.fixture 862 | def browser(config): 863 | 864 | # Initialize the WebDriver instance 865 | if config['browser'] == 'Firefox': 866 | b = selenium.webdriver.Firefox() 867 | elif config['browser'] == 'Chrome': 868 | b = selenium.webdriver.Chrome() 869 | elif config['browser'] == 'Headless Chrome': 870 | opts = selenium.webdriver.ChromeOptions() 871 | opts.add_argument('headless') 872 | b = selenium.webdriver.Chrome(options=opts) 873 | else: 874 | raise Exception(f'Browser "{config["browser"]}" is not supported') 875 | 876 | # Make its calls wait for elements to appear 877 | b.implicitly_wait(config['implicit_wait']) 878 | 879 | # Return the WebDriver instance for the setup 880 | yield b 881 | 882 | # Quit the WebDriver instance for the cleanup 883 | b.quit() 884 | ``` 885 | 886 | Fixtures can call fixtures. 887 | Here, `browser` calls `config` and then uses its parts to set the browser and implicit wait time. 888 | Notice that Headless Chrome just uses the Chrome WebDriver with extra arguments. 889 | 890 | Nothing else needs to be updated in order to change the browser. 891 | Run the test using `pipenv run python -m pytest` with Chrome to verify no harm was done. 892 | You should see the test run successfully. 893 | 894 | Then, change the config's *browser* to "Headless Chrome" and rerun the test. 895 | You won't see the browser window appear, but the test should still pass. 896 | Why? "Headless" mode won't render pages visibly. 897 | It's great for automated testing because it's slightly more efficient than "regular" Chrome. 898 | 899 | Finally, try "Firefox". Does it work? Warning: it may or may not! Oh no! 900 | Don't panic if it doesn't work. We'll fix it in the next part. 901 | 902 | ### Part 7: Handling Race Conditions 903 | 904 | *Time Estimate: 15 Minutes* 905 | 906 | *Example Branch: example/7-race-conditions* 907 | 908 | When running the search test using Firefox, you might hit the following failure: 909 | 910 | ``` 911 | # Then the search result title contains "panda" 912 | > assert PHRASE in result_page.title() 913 | E AssertionError: assert 'panda' in 'DuckDuckGo — Privacy, simplified.' 914 | ``` 915 | 916 | Or, the test might pass. 917 | Why would the test fail on Firefox if it passed for Chrome? 918 | And why is there a chance that it *might* fail or *might* pass? 919 | Let's revisit the test case steps: 920 | 921 | ```gherkin 922 | Scenario: Basic DuckDuckGo Search 923 | Given the DuckDuckGo home page is displayed 924 | When the user searches for “panda” 925 | Then the search result title contains “panda” 926 | And the search result query is “panda” 927 | And the search result links pertain to “panda” 928 | ``` 929 | 930 | Step 2 performs the search. 931 | Then, step 3 checks the title of the page. 932 | Unfortunately, step 3 has a *race condition*. 933 | Remember, the browser and the automation are two separate processes. 934 | When the automation triggers the search, the browser will load the new page and title. 935 | At the same time, the automation will continue to execute the test. 936 | If the automation executes the assertion *before* the new page title loads, 937 | then the assertion will fail. 938 | Chrome was fast enough to avoid the race condition, 939 | but Firefox was slow enough to trigger it. 940 | 941 | Race conditions are the bane of Web UI testing. 942 | They can be difficult to predict when writing tests. 943 | They can also be difficult to identify in test results 944 | because they typically happen *intermittently*. 945 | Web UI tests gain a bad reputation for being "flaky" 946 | whenever race conditions are not handled appropriately. 947 | 948 | Automation must always wait for page components to be ready before interacting with them. 949 | [Implicit waits](https://selenium-python.readthedocs.io/waits.html#implicit-waits) 950 | work well for Web elements, 951 | but they don't work for browser attributes like page title. 952 | They are best for small projects. 953 | [Explicit waits](https://selenium-python.readthedocs.io/waits.html#explicit-waits) 954 | are more customizable, but they require more code. 955 | They are typically the better option for large projects that need different times and conditions. 956 | As a best practice, automation should use only one type of waiting. 957 | Mixing implicit and explicit waits can have unexpected consequences. 958 | 959 | Thankfully, there's a shortcut we can use to fix `test_basic_duckduckgo_search`. 960 | The other two assertions use implicit waits for other elements on the page. 961 | By the time those elements are loaded, the title would also be loaded. 962 | Therefore, we can move step 3 to the end of the scenario to be the last thing we check. 963 | 964 | The updated code for `tests/test_search.py` should be: 965 | 966 | ```python 967 | """ 968 | These tests cover DuckDuckGo searches. 969 | """ 970 | 971 | from pages.result import DuckDuckGoResultPage 972 | from pages.search import DuckDuckGoSearchPage 973 | 974 | 975 | def test_basic_duckduckgo_search(browser): 976 | search_page = DuckDuckGoSearchPage(browser) 977 | result_page = DuckDuckGoResultPage(browser) 978 | PHRASE = "panda" 979 | 980 | # Given the DuckDuckGo home page is displayed 981 | search_page.load() 982 | 983 | # When the user searches for "panda" 984 | search_page.search(PHRASE) 985 | 986 | # Then the search result query is "panda" 987 | assert PHRASE == result_page.search_input_value() 988 | 989 | # And the search result links pertain to "panda" 990 | for title in result_page.result_link_titles(): 991 | assert PHRASE.lower() in title.lower() 992 | 993 | # And the search result title contains "panda" 994 | # (Putting this assertion last guarantees that the page title will be ready) 995 | assert PHRASE in result_page.title() 996 | ``` 997 | 998 | Rerun the test using `pipenv run python -m pytest` with Firefox to verify the fix. 999 | Then, rerun it again with Chrome and Headless Chrome to make sure those browsers still work. 1000 | 1001 | Always watch out for race conditions, 1002 | always wait for things to be ready before interacting with them, 1003 | and always run tests multiple times across multiple configurations to identify problems. 1004 | 1005 | ### Part 8: Running Tests in Parallel 1006 | 1007 | *Time Estimate: 15 Minutes* 1008 | 1009 | *Example Branch: example/8-parallel-testing* 1010 | 1011 | Unfortunately, Web UI tests are very slow compared to unit tests and service API tests. 1012 | The best way to speed them up is to run them in parallel. 1013 | 1014 | First, let's parametrize `test_basic_duckduckgo_search` so that we have more than one test to run. 1015 | Any pytest test or fixture may be [parametrized](https://docs.pytest.org/en/latest/parametrize.html). 1016 | Update the code in `tests/test_search.py` to be: 1017 | 1018 | ```python 1019 | """ 1020 | These tests cover DuckDuckGo searches. 1021 | """ 1022 | 1023 | import pytest 1024 | 1025 | from pages.result import DuckDuckGoResultPage 1026 | from pages.search import DuckDuckGoSearchPage 1027 | 1028 | 1029 | @pytest.mark.parametrize('phrase', ['panda', 'python', 'polar bear']) 1030 | def test_basic_duckduckgo_search(browser, phrase): 1031 | search_page = DuckDuckGoSearchPage(browser) 1032 | result_page = DuckDuckGoResultPage(browser) 1033 | 1034 | # Given the DuckDuckGo home page is displayed 1035 | search_page.load() 1036 | 1037 | # When the user searches for the phrase 1038 | search_page.search(phrase) 1039 | 1040 | # Then the search result query is the phrase 1041 | assert phrase == result_page.search_input_value() 1042 | 1043 | # And the search result links pertain to the phrase 1044 | for title in result_page.result_link_titles(): 1045 | assert phrase.lower() in title.lower() 1046 | 1047 | # And the search result title contains the phrase 1048 | # (Putting this assertion last guarantees that the page title will be ready) 1049 | assert phrase in result_page.title() 1050 | ``` 1051 | 1052 | The test will now run three times with different search phrases. 1053 | Rerun the tests to make sure they all work. 1054 | You will notice that they run one at a time. 1055 | 1056 | Next, install [pytest-xdist](https://docs.pytest.org/en/3.0.1/xdist.html), 1057 | the pytest plugin for parallel testing: 1058 | 1059 | ```bash 1060 | $ pipenv install pytest-xdist 1061 | ``` 1062 | 1063 | Finally, run the tests using the following command: 1064 | 1065 | ```bash 1066 | $ pipenv run python -m pytest -n 3 1067 | ``` 1068 | 1069 | The "-n 3" arguments tells pytest to run 3 tests in parallel. 1070 | We have 3 example tests, and most machines can handle 3 Web UI tests simultaneously. 1071 | When the tests run, notice how 3 browser instances open at once - one per test. 1072 | 1073 | Run the tests a few times using Chrome and Firefox. 1074 | Look to see how long the tests typically take per browser. 1075 | Also, look to see if any intermittent failures happen. 1076 | Then, try using Headless Chrome. 1077 | Most likely, Headless Chrome will be significantly faster and more reliable 1078 | that regular Chrome and Firefox. 1079 | 1080 | As a warning, parallel testing can be dangerous. 1081 | Make sure that tests avoid *collisions*. 1082 | Collisions happen when tests simultaneously access shared state. 1083 | For example, one test could try to access a database record while another test deletes it. 1084 | Thankfully, our DuckDuckGo search tests do not have any collisions 1085 | because they make independent searches in separate browser instances. 1086 | 1087 | Whenever running tests in parallel, 1088 | carefully tune the number of threads to minimize the total test execution time. 1089 | More threads does *not* necessarily mean faster testing. 1090 | Too many parallel tests will choke system resources. 1091 | 1092 | Anecdotally, for Web UI tests: 1093 | 1094 | * 1 test per processor minimizes total execution time without slowing down individual tests 1095 | * 2 tests per processor minimizes total execution time further but slows down individual tests 1096 | * more than 2 tests per processor does not meaningfully shrink total execution time further 1097 | * memory size does not have a significant impact on total execution time 1098 | 1099 | One machine can scale up only so far. 1100 | For massive parallel testing, try using 1101 | [Selenium Grid](https://github.com/SeleniumHQ/selenium/wiki/Grid2). 1102 | Alternatively, many companies provide cloud-based solutions for parallel WebDriver testing. 1103 | Check the *Resources* section below for a list. 1104 | 1105 | To learn more about parallel testing in general, read 1106 | [To Infinity and Beyond: A Guide to Parallel Testing](https://automationpanda.com/2018/01/21/to-infinity-and-beyond-a-guide-to-parallel-testing/). 1107 | 1108 | Congrats! You have completed the guided part of this tutorial! 1109 | 1110 | ## Independent Exercises 1111 | 1112 | The guided tutorial covered one very basic search test, but DuckDuckGo has many more features. 1113 | Try to write some new tests for DuckDuckGo independently. 1114 | Here are some suggestions: 1115 | 1116 | * search for different phrases 1117 | * search by clicking the button instead of typing RETURN 1118 | * click a search result 1119 | * expand "More Results" at the bottom of the result page 1120 | * verify auto-complete suggestions pertain to the search text 1121 | * search by selecting an auto-complete suggestion 1122 | * search a new phrase from the results page 1123 | * do an image search 1124 | * do a video search 1125 | * do a news search 1126 | * change settings 1127 | * change region 1128 | 1129 | These tests will require new page objects, locators, and interaction methods. 1130 | See how many tests you can automate on your own! 1131 | If you get stuck, ask for help. 1132 | 1133 | ## Additional Resources 1134 | 1135 | This DjangoCon 2019 *Hands-On Web UI Testing* tutorial is related to other tutorials by Andrew Knight: 1136 | 1137 | * PyOhio 2019: [Hands-On Web UI Testing](https://github.com/AndyLPK247/pyohio-2019-web-ui-testing) 1138 | * TestProject: [Web Testing Made Easy with Python, Pytest and Selenium WebDriver](https://blog.testproject.io/2019/07/09/open-source-test-automation-python-pytest-selenium-webdriver/) 1139 | * SmartBear: [Hands-On UI Testing with Python](https://automationpanda.com/2019/08/19/hands-on-ui-testing-with-python-smartbear-webinar/) 1140 | 1141 | [Test Automation University](https://testautomationu.applitools.com/) 1142 | offers free online courses on several testing and automation topics. 1143 | All TAU courses are great, but the following ones compliment this tutorial especially well: 1144 | 1145 | * [Web Element Locator Strategies](https://testautomationu.applitools.com/web-element-locator-strategies/) shows how to write good locators and use Chrome DevTools. 1146 | * [Behavior-Driven Python with pytest-bdd](https://testautomationu.applitools.com/behavior-driven-python-with-pytest-bdd/) shows how to use `pytest-bdd` to write BDD-style tests. 1147 | * [Setting a Foundation for Successful Test Automation](https://testautomationu.applitools.com/setting-a-foundation-for-successful-test-automation/) shows how to run a testing project the right way. 1148 | 1149 | Many companies provide cloud-based solutions for parallel, multi-browser, multi-platform Web UI testing that work with WebDriver: 1150 | 1151 | * [BrowserStack](https://www.browserstack.com/) 1152 | * [LambdaTest](https://www.lambdatest.com/) 1153 | * [Sauce Labs](https://saucelabs.com/) 1154 | * [SmartBear CrossBrowserTesting](https://crossbrowsertesting.com/) 1155 | * [TestProject](https://testproject.io/) 1156 | 1157 | Other helpful links: 1158 | 1159 | * [AutomationPanda.com](https://automationpanda.com/) 1160 | * [Python](https://automationpanda.com/python/) 1161 | * [Testing](https://automationpanda.com/testing/) 1162 | * [Why Python is Great for Test Automation](https://automationpanda.com/2018/07/26/why-python-is-great-for-test-automation/) 1163 | * [Web Element Locators for Test Automation](https://automationpanda.com/2019/01/15/web-element-locators-for-test-automation/) 1164 | * [The Testing Pyramid](https://automationpanda.com/2018/08/01/the-testing-pyramid/) 1165 | * [To Infinity and Beyond: A Guide to Parallel Testing](https://automationpanda.com/2018/01/21/to-infinity-and-beyond-a-guide-to-parallel-testing/) 1166 | * [Selenium with Python](https://selenium-python.readthedocs.io/) 1167 | * [WebDriver API](https://selenium-python.readthedocs.io/api.html) 1168 | * [Waits](https://selenium-python.readthedocs.io/waits.html) 1169 | * [Locating Elements](https://selenium-python.readthedocs.io/locating-elements.html) 1170 | * [pytest.org](https://docs.pytest.org/) 1171 | * [Selenium Grid wiki](https://github.com/SeleniumHQ/selenium/wiki/Grid2) 1172 | 1173 | ## Special Thanks from Pandy 1174 | 1175 | Thank you to [DjangoCon 2019](https://2019.djangocon.us/) for inviting me to deliver this tutorial! 1176 | 1177 | Thank you to all the students who participated in this tutorial at DjangoCon 2019. 1178 | 1179 | Thank you to the following individuals who graciously reviewed this tutorial: 1180 | 1181 | * Michael Lynch ([@deliberatecoder](https://twitter.com/deliberatecoder)) 1182 | * Satyank Tiwari ([@satyanktiwari](https://twitter.com/satyanktiwari)) 1183 | * Adeel Mansoor ([@testadeel](https://twitter.com/testadeel)) 1184 | * Rick Clymer ([@clymerrm](https://twitter.com/clymerrm)) 1185 | * Katrina Durance ([@katdurance](https://twitter.com/katdurance)) 1186 | 1187 | ## About the Author 1188 | 1189 | This tutorial was written and delivered by **Andrew Knight** (aka *Pandy*), the "Automation Panda". 1190 | Andy is a Pythonista who specializes in testing and automation. 1191 | 1192 | * Twitter: [@AutomationPanda](https://twitter.com/AutomationPanda) 1193 | * Blog: [AutomationPanda.com](https://automationpanda.com/) 1194 | * LinkedIn: [andrew-leland-knight](https://www.linkedin.com/in/andrew-leland-knight/) 1195 | --------------------------------------------------------------------------------