Skip to content

Commit 726b718

Browse files
authored
Allow unit and selenium tests to run in parallel (#1611)
1 parent fc654aa commit 726b718

File tree

9 files changed

+162
-82
lines changed

9 files changed

+162
-82
lines changed

.github/workflows/config.yml

+8-4
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,24 @@ jobs:
1313

1414
django-unit-tests:
1515
runs-on: ubuntu-latest
16+
env:
17+
IOGT_TEST_PARALLEL: "2"
1618
steps:
17-
- uses: actions/checkout@v2
19+
- uses: actions/checkout@v3
1820
- run: make test
19-
- uses: actions/upload-artifact@v2
21+
- uses: actions/upload-artifact@v3
2022
with:
2123
name: django-coverage-report
2224
path: htmlcov
2325

2426
selenium-tests:
2527
runs-on: ubuntu-latest
28+
env:
29+
IOGT_TEST_PARALLEL: "1"
2630
steps:
27-
- uses: actions/checkout@v2
31+
- uses: actions/checkout@v3
2832
- run: make selenium-test
29-
- uses: actions/upload-artifact@v2
33+
- uses: actions/upload-artifact@v3
3034
with:
3135
name: django-coverage-report
3236
path: htmlcov

Dockerfile.dev

+6-5
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ ENV PYTHONUNBUFFERED 1
44

55
WORKDIR /app
66

7-
RUN apt update -y
8-
RUN apt install gettext -y
7+
RUN apt-get update --yes --quiet \
8+
&& apt-get install --yes --quiet --no-install-recommends gettext tini \
9+
&& rm -rf /var/lib/apt/lists/*
910

10-
RUN pip install --upgrade pip
11-
ADD ./requirements.txt ./requirements.dev.txt ./
12-
RUN pip install -r requirements.dev.txt
11+
COPY requirements.txt requirements.dev.txt /
12+
RUN pip install --no-cache-dir --upgrade pip \
13+
&& pip install --no-cache-dir -r /requirements.dev.txt

Makefile

+5-4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
IOGT_TEST_PARALLEL ?= 1
2+
13
# Build the project
24
build:
35
docker-compose build
@@ -20,15 +22,14 @@ update_elasticsearch_index:
2022
test:
2123
docker-compose -f docker-compose.test.yml up --build -d django
2224
docker-compose -f docker-compose.test.yml exec -T django python manage.py collectstatic --noinput
23-
docker-compose -f docker-compose.test.yml exec -T django coverage run --source='.' manage.py test --noinput
25+
docker-compose -f docker-compose.test.yml exec -T django coverage run --source='.' manage.py test --noinput --parallel $(IOGT_TEST_PARALLEL)
2426
docker-compose -f docker-compose.test.yml exec -T django coverage html
2527
docker-compose -f docker-compose.test.yml down --remove-orphans
2628
selenium-test: selenium-up selenium-local selenium-down
2729
selenium-up:
28-
docker-compose -f docker-compose.selenium.yml up --build -d
30+
docker-compose -f docker-compose.selenium.yml up --build -d --scale chrome=$(IOGT_TEST_PARALLEL)
2931
docker-compose -f docker-compose.selenium.yml exec -T django python manage.py collectstatic --noinput
3032
selenium-local:
31-
docker-compose exec -T django python manage.py test selenium_tests
33+
docker-compose -f docker-compose.selenium.yml exec -T django python manage.py test selenium_tests --parallel $(IOGT_TEST_PARALLEL)
3234
selenium-down:
3335
docker-compose -f docker-compose.selenium.yml down --remove-orphans
34-

README.md

+13-3
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,20 @@ docker-compose run django python manage.py autopopulate_main_menus
129129

130130
## Running Tests
131131

132-
Run the following command:
132+
Run the following commands:
133133
```
134-
make test
134+
make tests
135+
make selenium-test
135136
```
136137

138+
In parallel, for example, with 4 processes:
139+
```
140+
IOGT_TEST_PARALLEL=4 make test
141+
IOGT_TEST_PARALLEL=4 make selenium-test
142+
```
143+
144+
More details of the Selenium tests can be found in the [Selenium test README][9].
145+
137146
## Configuring the Chatbot
138147

139148
Follow instructions [here](messaging/README.md)
@@ -184,4 +193,5 @@ Follow instructions [here](iogt_content_migration/README.md)
184193
[5]: https://wagtail.io/
185194
[6]: https://github.com/wagtail/wagtail/wiki/Release-schedule
186195
[7]: ./docs/cache.md
187-
[8]: ./docs/troubleshooting.md
196+
[8]: ./docs/troubleshooting.md
197+
[9]: ./selenium_tests/README.md

docker-compose.selenium.yml

+13-32
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,31 @@
11
version: '3.5'
22

33
services:
4-
54
django:
65
build:
76
context: ./
87
dockerfile: Dockerfile.dev
98
environment:
10-
DJANGO_SETTINGS_MODULE: iogt.settings.docker_compose_dev
11-
COMMIT_HASH: asdfghjkl
12-
WAGTAILTRANSFER_SECRET_KEY: wagtailtransfer-secret-key
13-
WAGTAILTRANSFER_SOURCE_NAME: iogt_global
14-
WAGTAILTRANSFER_SOURCE_BASE_URL: https://example.com/wagtail-transfer/
15-
WAGTAILTRANSFER_SOURCE_SECRET_KEY: wagtailtransfer-source-secret-key
16-
BASE_URL: 'http://localhost:8000'
17-
command: python manage.py runserver 0.0.0.0:8000
9+
DJANGO_SETTINGS_MODULE: iogt.settings.test
10+
command: ["tini", "--", "sleep", "infinity"]
1811
volumes:
1912
- ./:/app/
20-
ports:
21-
- "8000:8000"
2213
depends_on:
23-
- elasticsearch
14+
- database
2415
- selenium-hub
25-
26-
elasticsearch:
27-
image: 'docker.elastic.co/elasticsearch/elasticsearch:7.12.1'
16+
database:
17+
image: postgres:14-alpine
2818
environment:
29-
- discovery.type=single-node
30-
ports:
31-
- "9200:9200"
32-
19+
POSTGRES_PASSWORD: iogt
20+
selenium-hub:
21+
image: selenium/hub:4.9.1-20230508
3322
chrome:
34-
image: selenium/node-chrome:4.7.2-20221219
23+
image: selenium/node-chrome:4.9.1-20230508
3524
shm_size: 2gb
3625
depends_on:
3726
- selenium-hub
3827
environment:
39-
- SE_EVENT_BUS_HOST=selenium-hub
40-
- SE_EVENT_BUS_PUBLISH_PORT=4442
41-
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
42-
ports:
43-
- "5900:5900"
44-
45-
selenium-hub:
46-
image: selenium/hub:4.7.2-20221219
47-
ports:
48-
- "4442:4442"
49-
- "4443:4443"
50-
- "4444:4444"
28+
SE_EVENT_BUS_HOST: selenium-hub
29+
SE_EVENT_BUS_PUBLISH_PORT: "4442"
30+
SE_EVENT_BUS_SUBSCRIBE_PORT: "4443"
31+
SE_VNC_NO_PASSWORD: "1"

iogt/settings/test.py

+17-9
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
1+
from os import getenv
2+
13
from .base import *
24

3-
SECRET_KEY = "##secret_key_for_testing##"
45

6+
ALLOWED_HOSTS = ['*']
57
DATABASES = {
68
'default': {
79
'ENGINE': 'django.db.backends.postgresql',
8-
'NAME': os.environ.get('DB_NAME'),
9-
'USER': os.environ.get('DB_USER'),
10-
'PASSWORD': os.environ.get('DB_PASSWORD'),
11-
'HOST': os.environ.get('DB_HOST'),
12-
'PORT': os.environ.get('DB_PORT'),
10+
'NAME': getenv('DB_NAME', 'postgres'),
11+
'USER': getenv('DB_USER', 'postgres'),
12+
'PASSWORD': getenv('DB_PASSWORD', 'iogt'),
13+
'HOST': getenv('DB_HOST', 'database'),
14+
'PORT': getenv('DB_PORT', '5432'),
1315
}
1416
}
15-
17+
SE_APP_HOST = "django"
18+
SE_HUB_HOST = "selenium-hub"
19+
SE_HUB_PORT = "4444"
20+
SECRET_KEY = "##secret_key_for_testing##"
1621
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"
17-
18-
DEBUG = True
22+
WAGTAILSEARCH_BACKENDS = {
23+
'default': {
24+
'BACKEND': 'wagtail.search.backends.database',
25+
}
26+
}

requirements.dev.txt

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@ Faker
55
beautifulsoup4
66
django-test-plus>=1.4.0
77
coverage
8-
selenium==4.7.2
8+
selenium==4.9.1
99
factory-boy==3.2.*
10-
wagtail-factories==4.0.*
10+
wagtail-factories==4.0.*

selenium_tests/README.md

+35-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Selenium Tests
22

3-
We are using Selenium to carry out functional testing in the IoGT project. Specifically, we are using Selenium grid which gives us the option to run tests on multiple browsers.
3+
We are using Selenium to carry out functional testing in the IoGT project. Specifically, we are using Selenium Grid which gives us the option to run tests on multiple browsers.
44

55
The functional tests have been broken down into Categories > Groups > Tests.
66

@@ -13,7 +13,7 @@ For general guidance on how to write Selenium tests, review the existing tests w
1313

1414
The tests are configured to run automatically through GitHub actions, although this may not be practical during intial test development.
1515

16-
The Selenium Grid and WebDriver is run from a Docker container. Therefore, to run tests locally you need to install Docker Desktop and have it running on your computer.
16+
The Selenium Grid and WebDriver is run from a Docker container. Therefore, to run tests locally you need to install Docker and have it running on your computer.
1717

1818
Then run the following commands:
1919
```
@@ -26,7 +26,38 @@ To run the tests themselves, run:
2626
make selenium-local
2727
```
2828

29-
You can view the tests as they run locally using a number of [different methods][1].
29+
You can view the tests as they run locally via [a couple of different methods][1] - namely VNC and noVNC.
3030

31+
## Running tests in parallel
3132

32-
[1]: https://github.com/SeleniumHQ/docker-selenium#using-a-vnc-client
33+
Make sure that no ports are being exposed from the Selenium nodes. For example, don't use the 'ports' option to expose the VNC port (5900) because this will prevent two or more Selenium nodes from starting by causing them all try to bind to port 5900 on the host.
34+
35+
Scale up the number of Selenium nodes.
36+
```
37+
docker-compose -f docker-compose.selenium.yml up -d --scale chrome=4
38+
```
39+
40+
Run the tests in parallel.
41+
```
42+
docker-compose -f docker-compose.selenium.yml exec -T django python manage.py test selenium_tests --parallel 4
43+
```
44+
45+
The environment variable `IOGT_TEST_PARALLEL` when running the whole test suite.
46+
```
47+
IOGT_TEST_PARALLEL=4 make selenium-test
48+
```
49+
50+
## Architecture
51+
52+
App service container starts and sleeps - waiting for test executions. A sleeping service is required so that the Selenium nodes can target the test server that is started by the test suite.
53+
54+
PostgreSQL service - used instead of Sqlite for better multi-threaded performance and for being more like a production environment.
55+
56+
Selenium Hub - to route webdriver commands to Selenium nodes
57+
58+
Selenium Nodes - currently only Chrome, but other nodes with different browsers and capabilities could be created e.g. Firefox, Safari, Edge, or with Javascript disabled and/or mobile features enabled.
59+
60+
Once all services are started, test executions can be started on the app service. Each test execution starts a test app server, creates a new test database, then begins running tests. When run in parallel, the main test runner will start several child test runners that each create their own test server and database, and requires a single Selenium node to run tests in.
61+
62+
63+
[1]: https://github.com/SeleniumHQ/docker-selenium#debugging

selenium_tests/base.py

+63-19
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,25 @@
1+
from django.db import connections
2+
from django.conf import settings
3+
from django.core.management import call_command
14
from django.test import LiveServerTestCase
25
from selenium import webdriver
36
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
4-
from wagtail.core.models import Page
5-
from selenium_tests.pages import BasePage
6-
from selenium_tests.pages import LoginPage
7-
from selenium_tests.pages import LogoutPage
8-
from wagtail.core.models import Site
7+
from wagtail.core.models import Page, Site
98
from wagtail_factories import SiteFactory
9+
1010
from home.factories import HomePageFactory
11+
from selenium_tests.pages import BasePage, LoginPage, LogoutPage
12+
1113

1214
class BaseSeleniumTests(LiveServerTestCase):
1315

14-
fixtures = ['selenium_tests/locales.json']
16+
fixtures = ['selenium_tests/locales.json']
1517

16-
host = 'django'
17-
port = 9000
18+
host = settings.SE_APP_HOST
1819

1920
@classmethod
2021
def setUpClass(cls):
21-
options = webdriver.ChromeOptions()
22-
options.add_argument('--ignore-ssl-errors=yes')
23-
options.add_argument('--ignore-certificate-errors')
24-
options.add_argument("--window-size=480,720")
25-
options.add_argument("--start-maximized")
26-
cls.selenium = webdriver.Remote(
27-
command_executor='http://selenium-hub:4444/wd/hub',
28-
desired_capabilities=DesiredCapabilities.CHROME,
29-
options=options
30-
)
31-
cls.selenium.implicitly_wait(10)
22+
cls.selenium = create_remote_webdriver()
3223
super(BaseSeleniumTests, cls).setUpClass()
3324

3425
@classmethod
@@ -62,3 +53,56 @@ def setup_blank_site(self):
6253
is_default_site=True,
6354
root_page=self.home
6455
)
56+
57+
# https://github.com/wagtail/wagtail/issues/1824#issuecomment-450575883
58+
# Identical to TransactionTestCase._fixture_teardown except that 'allow_cascade' is
59+
# forced.
60+
def _fixture_teardown(self):
61+
# Allow TRUNCATE ... CASCADE and don't emit the post_migrate signal
62+
# when flushing only a subset of the apps
63+
for db_name in self._databases_names(include_mirrors=False):
64+
# Flush the database
65+
inhibit_post_migrate = (
66+
self.available_apps is not None or
67+
( # Inhibit the post_migrate signal when using serialized
68+
# rollback to avoid trying to recreate the serialized data.
69+
self.serialized_rollback and
70+
hasattr(connections[db_name], '_test_serialized_contents')
71+
)
72+
)
73+
call_command('flush', verbosity=0, interactive=False,
74+
database=db_name, reset_sequences=False,
75+
allow_cascade=True,
76+
inhibit_post_migrate=inhibit_post_migrate)
77+
78+
79+
def create_remote_webdriver(preset: str = "chrome") -> webdriver.Remote:
80+
if preset == "chrome":
81+
options = webdriver.ChromeOptions()
82+
options.add_argument('--ignore-ssl-errors=yes')
83+
options.add_argument('--ignore-certificate-errors')
84+
options.add_argument("--window-size=480,720")
85+
options.add_argument("--start-maximized")
86+
driver = webdriver.Remote(
87+
command_executor=get_hub_url(),
88+
desired_capabilities=DesiredCapabilities.CHROME,
89+
options=options,
90+
)
91+
elif preset == "firefox":
92+
options = webdriver.FirefoxOptions()
93+
driver = webdriver.Remote(
94+
command_executor=get_hub_url(),
95+
desired_capabilities=DesiredCapabilities.FIREFOX,
96+
options=options,
97+
)
98+
else:
99+
raise Exception(
100+
f"Invalid webdriver preset ('{preset}'); must be 'chrome' or 'firefox'"
101+
)
102+
driver.set_page_load_timeout(60)
103+
driver.implicitly_wait(10)
104+
return driver
105+
106+
107+
def get_hub_url():
108+
return f"http://{settings.SE_HUB_HOST}:{settings.SE_HUB_PORT}/wd/hub"

0 commit comments

Comments
 (0)