Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a701342
[AB#307495]-setup selenium base + add create program test
mmaciekk Mar 27, 2026
1b32f33
fix tox.ini / pipeline setup for headless selenium required by seleni…
mmaciekk Mar 27, 2026
463d279
admin panel url
mmaciekk Mar 27, 2026
cc1fd4e
add new rules to selenium_base, refactor test_create_program to adjus…
mmaciekk Mar 30, 2026
b675c81
replace sleep wit wait for element
mmaciekk Mar 30, 2026
93c9b9e
add rule about wait for element instead of sleep, and remove flag fro…
mmaciekk Mar 30, 2026
d6d3f4e
format, lint
mmaciekk Mar 30, 2026
8eb978f
PR comments addressed
mmaciekk Mar 31, 2026
076de75
use select_option_by_name instead of select_listbox_element
mmaciekk Mar 31, 2026
c4a8074
add new pattern
mmaciekk Mar 31, 2026
9661d3a
make selenium patterns more concise
mmaciekk Apr 1, 2026
870a41b
remove if inside test function
mmaciekk Apr 1, 2026
dc12f1f
remove comments scroll and if
mmaciekk Apr 1, 2026
2ca9194
final pr comments
mmaciekk Apr 1, 2026
a922ed1
Merge branch 'develop' into selenium-create-program
mmaciekk Apr 1, 2026
423f41f
test pdu fields and other fields from program form
mmaciekk Apr 1, 2026
3c136b9
fix add assert value
mmaciekk Apr 1, 2026
0a32d07
fix edit pdu test + button next instead of clicking on step button
mmaciekk Apr 1, 2026
b9128dc
Merge remote-tracking branch 'origin' into selenium-create-program
mmaciekk Apr 10, 2026
0e6587b
address Kamil's comments
mmaciekk Apr 10, 2026
05b2593
uv.lock update
mmaciekk Apr 10, 2026
9fa8449
rewrite flaky grievance test selenium base way
mmaciekk Apr 10, 2026
fab5d14
remove pytest.fixture(autouse=True)
mmaciekk Apr 15, 2026
54a9968
Merge branch 'develop' into selenium-create-program
mmaciekk Apr 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ jobs:
./tox
key: ${{ runner.os }}-venv


- name: Run Test suite
run: uv run tox -e tests -- pytest ./tests/unit -q -rfE --no-header --tb=short --no-migrations --randomly-seed=42 --dist=loadgroup --create-db -n 4 --cov-report xml:test-coverage/coverage.xml --junit-xml=test-results.xml --cov-config=.coveragerc --cov=hope --dist loadgroup

Expand Down Expand Up @@ -232,7 +231,7 @@ jobs:
key: ${{ runner.os }}-venv

- name: Run Selenium suite
run: uv run tox -e tests -- pytest ./tests/e2e -q -rfE --no-header --tb=short --no-migrations --randomly-seed=42 --dist=loadgroup --create-db -n auto --cov-report xml:test-coverage/coverage.xml --junit-xml=test-results.xml --cov-config=.coveragerc --cov=hope
run: uv run tox -e tests -- pytest ./tests/e2e -q -rfE --no-header --tb=short --no-migrations --randomly-seed=42 --dist=loadgroup --create-db -n auto --headless --cov-report xml:test-coverage/coverage.xml --junit-xml=test-results.xml --cov-config=.coveragerc --cov=hope
- name: Test Report
uses: dorny/test-reporter@v2
if: success() || failure()
Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ dev = [
"pytest-cov>=4.1",
"pytest-django>=4.5.2",
"pytest-echo>=1.7.1",
"pytest-html>=4.1.1",
"pytest-html>=4.0.2",
"pytest-mock",
"pytest-randomly>=3.15",
"pytest-repeat>=0.9.3",
Expand All @@ -156,6 +156,7 @@ dev = [
"responses>=0.22",
"ruff>=0.11.8",
"selenium>=4.29",
"seleniumbase>=4.34",
"snapshottest>=1.0.0a0",
"tox>=4.25",
"types-freezegun>=1.1.10",
Expand Down Expand Up @@ -211,6 +212,9 @@ django_settings_module = "hope.settings"

[tool.uv]
package = true
override-dependencies = [
"pytest-html==4.0.2",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm not a fan of these overrides

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@domdinicola Me neither, but here is the reason why.
seleniumbase/SeleniumBase#3619

]

[tool.nitpick]
style = [
Expand Down
256 changes: 256 additions & 0 deletions tests/e2e/SELENIUM_E2E_PATTERNS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
# Selenium E2E Test Patterns — HOPE Project

Reference guide for writing SeleniumBase E2E tests in the HOPE project.

**Key files:**

- `tests/e2e/helpers/selenium_base.py` — `HopeTestBrowser` (extends `seleniumbase.BaseCase`)
- `tests/e2e/new_selenium/conftest.py` — shared `browser`, `login`,
fixtures
- `tests/e2e/conftest.py` — autouse DB fixtures (`create_super_user`, `create_unicef_partner`, etc.)

---

## Test Style Rules (MANDATORY)

**These rules are non-negotiable. Violations will cause test failures or flaky runs.**

1. **No `autouse=True`** on test data fixtures. Only infrastructure fixtures
(e.g. `test_failed_check`, `clear_default_cache`) may be autouse.
Test data fixtures must be explicitly requested by the test function.
2. **No `browser.sleep()`** — use `wait_for_element_clickable`, `wait_for_element_visible`,
or `wait_for_element_absent` instead. Only as a last resort for CSS animations.
3. Tests MUST be plain functions (`def test_*()`), never classes.
4. One test = one scenario. Use `pytest.mark.parametrize` instead of loops.
5. No `if / for / while` inside test bodies or test helper functions.
Loops in helpers are acceptable only for repetitive DOM actions (e.g. filling N round-name inputs).
6. Test data created exclusively in fixtures using factories from
`extras.test_utils.factories` (not `old_factories`).
7. Use `db` fixture, NOT `transaction=True` / `transactional_db`.
8. Prefer CSS selectors with `data-cy` attributes over XPath.
9. Use `login` fixture directly — do not alias (`browser = login`).
10. Mock only external dependencies (network, S3, Celery). Never mock code under test.

---

## Architecture

```
HopeTestBrowser (extends seleniumbase.BaseCase)
└── browser fixture (tests/e2e/new_selenium/conftest.py)
└── login fixture
└── test functions using login.click(), login.type(), login.assert_text()
```

`HopeTestBrowser` provides HOPE-specific helpers on top of SeleniumBase:

- `login(username, password)` — logs in via Django admin, clears browser storage
- `select_listbox_element(name)` — selects from MUI `ul[role="listbox"]` dropdowns
- `select_option_by_name(name)` — selects from `data-cy="select-option-*"` dropdowns
- `scroll_main_content(scroll_by)` — scrolls the MUI main content area

Existing raw Selenium tests (`Common → BaseComponents → PageObject`) are unaffected.

---

## Fixture Setup

The `browser` and `login` fixtures are defined in `tests/e2e/new_selenium/conftest.py`.
Do NOT redefine them. Create local `conftest.py` only for domain-specific data fixtures.

### Autouse fixtures (from parent conftest.py)
Comment thread
mmaciekk marked this conversation as resolved.

| Fixture | Creates |
| ---------------------------------- | ----------------------------------------------------- |
| `create_super_user` | User (`superuser`/`testtest2`), partners, roles, DCTs |
| `create_unicef_partner` | UNICEF + UNICEF HQ partners |
| `create_role_with_all_permissions` | Role with all permissions |
| `clear_default_cache` | Clears Django cache |

### On-demand fixtures

| Fixture | Creates |
| ------------------------- | ------------------------------------ |
| `business_area` | Afghanistan BA with flags, settings |
| `live_server_with_static` | Django live server with static files |

---

## SeleniumBase API Quick Reference

Full docs: [seleniumbase.io/help_docs/method_summary](https://seleniumbase.io/help_docs/method_summary/)

### Core methods
Comment thread
mmaciekk marked this conversation as resolved.
Outdated

```python
# Navigation
browser.open(url) # HopeTestBrowser prepends live_server_url
browser.get_current_url()

# Interaction
browser.click(selector)
browser.type(selector, text) # Clear + type (replaces existing)
browser.send_keys(selector, text) # Append text (doesn't clear)
browser.js_click(selector) # Click via JavaScript (for obscured elements)
browser.scroll_to(selector)

# Waiting (USE THESE instead of sleep)
browser.wait_for_element_visible(selector, timeout=None)
browser.wait_for_element_absent(selector, timeout=None)
browser.wait_for_element_clickable(selector, timeout=None)
browser.wait_for_text(text, selector="html", timeout=None)
browser.wait_for_ready_state_complete()

# Assertions
browser.assert_element(selector) # Visible in viewport
browser.assert_text(text, selector) # Text present in element
browser.assert_exact_text(text, selector)
browser.assert_element_absent(selector)
browser.assert_url_contains(substring)

# Reading
browser.get_text(selector)
browser.get_attribute(selector, attr)
browser.find_element(selector) # Returns WebElement
browser.find_elements(selector) # Returns list of WebElements
```

### Wait instead of sleep

```python
# BAD
browser.click(INPUT_NAME) # dismiss picker
browser.sleep(0.3) # flaky
browser.click(INPUT_END_DATE)

# GOOD
browser.click(INPUT_NAME) # dismiss picker
browser.wait_for_element_clickable(INPUT_END_DATE)
browser.click(INPUT_END_DATE)
```

### Fetch element once for click + type

```python
# BAD — two DOM lookups
browser.click(INPUT_START_DATE)
browser.send_keys(INPUT_START_DATE, "2024-01-01")

# GOOD — one lookup
el = browser.find_element(INPUT_START_DATE)
el.click()
el.send_keys("2024-01-01")
```

---

## Selector Conventions

All interactive elements use `data-cy` attributes. Common patterns:

```python
# Buttons
'a[data-cy="button-new-program"]'
'button[data-cy="button-next"]'
'button[data-cy="button-save"]'

# Inputs
'input[data-cy="input-name"]'
'textarea[data-cy="input-description"]'
'input[name="startDate"]' # date inputs use name attr

# Dropdowns (click to open, then use helper)
'div[data-cy="select-sector"]' # → browser.select_option_by_name("Child Protection")
'div[data-cy="input-beneficiary-group"]' # → browser.select_listbox_element("Main Menu")

# Labels / display values
'div[data-cy="label-Sector"]'
'h5[data-cy="page-header-title"]'
'div[data-cy="status-container"]'

# Navigation
'a[data-cy="nav-Programmes"]'
'a[data-cy="nav-Programme Details"]'
```

Discover selectors in the frontend source or browser DevTools — search for `data-cy`.

---

## Writing a New Test

### 1. File structure

```
tests/e2e/new_selenium/<module>/test_<feature>.py
tests/e2e/new_selenium/<module>/conftest.py # only if domain fixtures needed
Comment thread
mmaciekk marked this conversation as resolved.
Outdated
```

### 2. Template
Comment thread
mmaciekk marked this conversation as resolved.

```python
import pytest
from extras.test_utils.selenium import HopeTestBrowser

pytestmark = pytest.mark.django_db()

# Selectors as module-level constants
HEADER = 'h5[data-cy="page-header-title"]'
BTN_ACTION = 'button[data-cy="button-action"]'


def test_feature_scenario(login: HopeTestBrowser) -> None:
# Navigate
login.click('a[data-cy="nav-Programmes"]')
login.wait_for_text("Expected Title", HEADER)

# Interact
login.click(BTN_ACTION)
login.type('input[data-cy="input-field"]', "value")

# Assert
login.assert_text("Expected Text", 'div[data-cy="label-Field"]')
```

### 3. Domain fixture example (local conftest.py)
Comment thread
mmaciekk marked this conversation as resolved.
Outdated

```python
import pytest
from hct_mis_api.apps.account.fixtures import PartnerFactory, RoleFactory
from hct_mis_api.apps.core.models import BusinessArea
from hct_mis_api.apps.account.models import RoleAssignment

@pytest.fixture
def unhcr_partner():
partner = PartnerFactory(name="UNHCR")
ba = BusinessArea.objects.get(slug="afghanistan")
partner.allowed_business_areas.add(ba)
# ... setup as needed
return partner
```

---

## Running Tests

```bash
# Run SeleniumBase tests
tox -e tests -- tests/e2e/new_selenium/

# Single test file
tox -e tests -- tests/e2e/new_selenium/program_details/test_create_program.py

# Single test
tox -e tests -- tests/e2e/new_selenium/program_details/test_create_program.py::test_name

# Visible browser
tox -e tests -- tests/e2e/new_selenium/ --headed

# Slow mode (demo)
tox -e tests -- tests/e2e/new_selenium/ --headed --demo

# Lint after changes
tox -e lint
```

tox runs: `pytest -q --create-db --no-migrations --dist=loadgroup {posargs:tests}`
12 changes: 2 additions & 10 deletions tests/e2e/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ def create_session(host: str, username: str, password: str, csrf: str = "") -> o
"Content-Type": "application/x-www-form-urlencoded",
}
data = {"username": username, "password": password}
pytest.session.post(f"{host}/api/unicorn/login/", data=data, headers=headers)
pytest.session.post(f"{host}/api/{settings.ADMIN_PANEL_URL}/login/", data=data, headers=headers)
pytest.SESSION_ID = pytest.session.cookies.get_dict()["sessionid"]
return pytest.session

Expand Down Expand Up @@ -271,7 +271,7 @@ def browser(driver: Chrome, live_server_with_static) -> Chrome:

@pytest.fixture
def login(browser: Chrome) -> Chrome:
browser.get(f"{browser.live_server.url}/api/unicorn/")
browser.get(f"{browser.live_server.url}/api/{settings.ADMIN_PANEL_URL}/")

browser.execute_script(
"""
Expand Down Expand Up @@ -712,14 +712,6 @@ def pytest_runtest_makereport(item: Item, call: CallInfo[None]) -> None:


@pytest.fixture(autouse=True)
def test_failed_check(request: FixtureRequest, browser: Chrome) -> None:
yield
if request.node.rep_setup.failed:
pass
elif request.node.rep_setup.passed and request.node.rep_call.failed:
screenshot(browser, request.node.nodeid)


def attach(data=None, path=None, name="attachment", mime_type=None, request=None):
Comment thread
mmaciekk marked this conversation as resolved.
"""Drop-in replacement for pytest_html_reporter's attach()."""
if request is None: # fallback: can't attach without test context
Expand Down
Empty file.
27 changes: 27 additions & 0 deletions tests/e2e/new_selenium/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from typing import Generator

import pytest
from seleniumbase import config as sb_config

from extras.test_utils.selenium import HopeTestBrowser


@pytest.fixture
def browser(live_server_with_static, request) -> Generator[HopeTestBrowser, None, None]:
sb = HopeTestBrowser("base_method")
sb.live_server_url = str(live_server_with_static)
sb.setUp()
sb._needs_tearDown = True
sb._using_sb_fixture = True
sb._using_sb_fixture_no_class = True
sb_config._sb_node[request.node.nodeid] = sb
yield sb
if sb._needs_tearDown:
sb.tearDown()
sb._needs_tearDown = False


@pytest.fixture
def login(browser: HopeTestBrowser) -> HopeTestBrowser:
browser.login()
return browser
Empty file.
23 changes: 23 additions & 0 deletions tests/e2e/new_selenium/program_details/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import pytest

from extras.test_utils.factories.account import (
PartnerFactory,
RoleAssignmentFactory,
RoleFactory,
)
from hope.models import BusinessArea


@pytest.fixture
def unhcr_partner() -> None:
"""Create UNHCR partner with a role in Afghanistan."""
Comment thread
mmaciekk marked this conversation as resolved.
Outdated
partner_unhcr = PartnerFactory(name="UNHCR")
afghanistan = BusinessArea.objects.get(slug="afghanistan")
partner_unhcr.role_assignments.all().delete()
partner_unhcr.allowed_business_areas.add(afghanistan)
RoleAssignmentFactory(
partner=partner_unhcr,
business_area=afghanistan,
role=RoleFactory(name="Role for UNHCR"),
program=None,
)
Loading
Loading