Repository: cal-itp/benefits

Issue:
Unit test session management
We should be able to mock up dependencies and write unit tests for [`benefits.core.session`](https://github.com/cal-itp/benefits/blob/dev/benefits/core/session.py)

Django testing overview: https://docs.djangoproject.com/en/3.2/topics/testing/overview/

Here's a cool (and fairly straightforward) example of mocking a Django environment/`settings.py` ahead of running tests: https://github.com/fsbraun/django-automations/blob/master/runtests.py

_(Note the call out to the Django [`test` command](https://docs.djangoproject.com/en/3.2/topics/testing/overview/#running-tests)):_

```python
from django.core.management import call_command

# settings.configure()

# django.setup()

failures = call_command("test", test_path, interactive=False, failfast=False, verbosity=2)
```


Discussion:

Resolving patch:
diff --git a/benefits/core/session.py b/benefits/core/session.py
--- a/benefits/core/session.py
+++ b/benefits/core/session.py
@@ -113,11 +113,20 @@ def enrollment_token_expiry(request):
     return request.session.get(_ENROLLMENT_TOKEN_EXP)
 
 
-def increment_rate_limit_counter(request):
-    """Adds 1 to this session's rate limit counter."""
-    logger.debug("Increment rate limit counter")
-    c = rate_limit_counter(request)
-    request.session[_LIMITCOUNTER] = int(c) + 1
+def enrollment_token_valid(request):
+    """True if the request's session is configured with a valid token. False otherwise."""
+    if bool(enrollment_token(request)):
+        logger.debug("Session contains an enrollment token")
+        exp = enrollment_token_expiry(request)
+
+        # ensure token does not expire in the next 5 seconds
+        valid = exp is None or exp > (time.time() + 5)
+
+        logger.debug(f"Session enrollment token is {'valid' if valid else 'expired'}")
+        return valid
+    else:
+        logger.debug("Session does not contain a valid enrollment token")
+        return False
 
 
 def language(request):
@@ -126,6 +135,16 @@ def language(request):
     return request.LANGUAGE_CODE
 
 
+def logged_in(request):
+    """Check if the current session has an OAuth token."""
+    return bool(oauth_token(request))
+
+
+def logout(request):
+    """Reset the session tokens."""
+    update(request, oauth_token=False, enrollment_token=False)
+
+
 def oauth_token(request):
     """Get the oauth token from the request's session, or None"""
     logger.debug("Get session oauth token")
@@ -144,6 +163,21 @@ def rate_limit_counter(request):
     return request.session.get(_LIMITCOUNTER)
 
 
+def increment_rate_limit_counter(request):
+    """Adds 1 to this session's rate limit counter."""
+    logger.debug("Increment rate limit counter")
+    c = rate_limit_counter(request)
+    request.session[_LIMITCOUNTER] = int(c) + 1
+
+
+def reset_rate_limit(request):
+    """Reset this session's rate limit counter and time."""
+    logger.debug("Reset rate limit")
+    request.session[_LIMITCOUNTER] = 0
+    # get the current time in Unix seconds, then add RATE_LIMIT_PERIOD seconds
+    request.session[_LIMITUNTIL] = int(time.time()) + settings.RATE_LIMIT_PERIOD
+
+
 def rate_limit_time(request):
     """Get this session's rate limit time, a Unix timestamp after which the session's rate limt resets."""
     logger.debug("Get rate limit time")
@@ -170,14 +204,6 @@ def reset(request):
         reset_rate_limit(request)
 
 
-def reset_rate_limit(request):
-    """Reset this session's rate limit counter and time."""
-    logger.debug("Reset rate limit")
-    request.session[_LIMITCOUNTER] = 0
-    # get the current time in Unix seconds, then add RATE_LIMIT_PERIOD seconds
-    request.session[_LIMITUNTIL] = int(time.time()) + settings.RATE_LIMIT_PERIOD
-
-
 def start(request):
     """Get the start time from the request's session, as integer milliseconds since Epoch."""
     logger.debug("Get session time")
@@ -243,22 +269,6 @@ def update(
         request.session[_VERIFIER] = verifier.id
 
 
-def valid_enrollment_token(request):
-    """True if the request's session is configured with a valid token. False otherwise."""
-    if enrollment_token(request) is not None:
-        logger.debug("Session contains an enrollment token")
-        exp = enrollment_token_expiry(request)
-
-        # ensure token does not expire in the next 5 seconds
-        valid = exp is None or exp > (time.time() + 5)
-
-        logger.debug(f"Session enrollment token is {'valid' if valid else 'expired'}")
-        return valid
-    else:
-        logger.debug("Session does not contain a valid enrollment token")
-        return False
-
-
 def verifier(request):
     """Get the verifier from the request's session, or None"""
     logger.debug("Get session verifier")
@@ -267,11 +277,3 @@ def verifier(request):
     except (KeyError, models.EligibilityVerifier.DoesNotExist):
         logger.debug("Can't get verifier from session")
         return None
-
-
-def logged_in(request):
-    return bool(oauth_token(request))
-
-
-def logout(request):
-    update(request, oauth_token=False, enrollment_token=False)
diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py
--- a/benefits/enrollment/views.py
+++ b/benefits/enrollment/views.py
@@ -97,7 +97,7 @@ def _enroll(request):
 @decorator_from_middleware(EligibleSessionRequired)
 def token(request):
     """View handler for the enrollment auth token."""
-    if not session.valid_enrollment_token(request):
+    if not session.enrollment_token_valid(request):
         agency = session.agency(request)
         response = api.Client(agency).access_token()
         session.update(request, enrollment_token=response.access_token, enrollment_token_exp=response.expiry)


Test patch:
diff --git a/docs/getting-started/testing.md b/docs/getting-started/testing.md
--- a/docs/getting-started/testing.md
+++ b/docs/getting-started/testing.md
@@ -72,8 +72,20 @@ See `tests/cypress/package.json` for more cypress scripts.
 
 ## Pytest
 
-The tests done at a request/unit level are run via [pytest-django](https://pytest-django.readthedocs.io/en/latest/index.html). To run locally, start the [Devcontainer](../development/README.md) and run:
+The tests done at a request/unit level are run via [pytest-django](https://pytest-django.readthedocs.io/en/latest/index.html).
+
+To run locally, start the [Devcontainer](../development/README.md) and run:
 
 ```bash
-pytest --cov=benefits
+tests/pytest/run.sh
 ```
+
+The helper script:
+
+1. Runs the tests with `pytest`
+2. Calculates test coverage with [`coverage`](https://coverage.readthedocs.io/en/latest/)
+3. Generates a `coverage` report in HTML in the app's `static/` directory
+
+The report can be viewed by launching the app and navigating to `http://localhost:$DJANGO_LOCAL_PORT/static/coverage/index.html`
+
+The report files include a local `.gitignore` file, so the entire directory is hidden from source control.
diff --git a/tests/pytest/conftest.py b/tests/pytest/conftest.py
--- a/tests/pytest/conftest.py
+++ b/tests/pytest/conftest.py
@@ -1,7 +1,10 @@
 from django.contrib.sessions.middleware import SessionMiddleware
+from django.middleware.locale import LocaleMiddleware
 
 import pytest
 
+from benefits.core import session
+
 
 @pytest.fixture(scope="session")
 def django_db_setup():
@@ -10,9 +13,9 @@ def django_db_setup():
 
 
 @pytest.fixture
-def session_request(request, rf):
+def app_request(request, rf):
     """
-    Fixture creates a new Django request object and initializes its session.
+    Fixture creates and initializes a new Django request object similar to a real application request.
 
     Optionally use @pytest.mark.request_path(path: str) to customize the request's path.
     """
@@ -24,17 +27,22 @@ def session_request(request, rf):
     else:
         request_path = marker.args[0]
 
-    # create a request for the path, initialize session
-    session_request = rf.get(request_path)
-    initialize_session(session_request)
+    # create a request for the path, initialize
+    app_request = rf.get(request_path)
+    initialize_request(app_request)
 
-    return session_request
+    return app_request
 
 
-def initialize_session(request):
-    """Helper initializes a Django request object's session."""
+def initialize_request(request):
+    """Helper initializes a Django request object with session and language information."""
 
     # https://stackoverflow.com/a/55530933/358804
-    middleware = SessionMiddleware(lambda x: x)
-    middleware.process_request(request)
+    middleware = [SessionMiddleware(lambda x: x), LocaleMiddleware(lambda x: x)]
+
+    for m in middleware:
+        m.process_request(request)
+
     request.session.save()
+
+    session.reset(request)
diff --git a/tests/pytest/core/__init__.py b/tests/pytest/core/__init__.py
new file mode 100644
diff --git a/tests/pytest/test_login.py b/tests/pytest/core/test_middleware_loginrequired.py
similarity index 92%
rename from tests/pytest/test_login.py
rename to tests/pytest/core/test_middleware_loginrequired.py
--- a/tests/pytest/test_login.py
+++ b/tests/pytest/core/test_middleware_loginrequired.py
@@ -1,13 +1,14 @@
-from unittest.mock import create_autospec
+from django.urls import reverse
+from django.utils.decorators import decorator_from_middleware
 
 import pytest
+from unittest.mock import create_autospec
+
 from benefits.core import session
 from benefits.core.middleware import LoginRequired
 from benefits.core.models import EligibilityVerifier
-from django.urls import reverse
-from django.utils.decorators import decorator_from_middleware
 
-from tests.pytest.conftest import initialize_session
+from tests.pytest.conftest import initialize_request
 
 login_path = reverse("oauth:login")
 
@@ -29,7 +30,7 @@ def test_view(request):
 
     blocked_path = "/some/blocked/path"
     request = rf.get(blocked_path)
-    initialize_session(request)
+    initialize_request(request)
     require_login(request)
 
     response = test_view(request)
@@ -50,7 +51,7 @@ def test_view(request):
 
     blocked_path = "/some/blocked/path"
     request = rf.get(blocked_path)
-    initialize_session(request)
+    initialize_request(request)
 
     verifier = EligibilityVerifier.objects.filter(auth_provider__isnull=True).first()
     assert verifier
@@ -72,7 +73,7 @@ def test_view(request):
 
     blocked_path = "/some/blocked/path"
     request = rf.get(blocked_path)
-    initialize_session(request)
+    initialize_request(request)
     require_login(request)
 
     # log in
diff --git a/tests/pytest/core/test_session.py b/tests/pytest/core/test_session.py
new file mode 100644
--- /dev/null
+++ b/tests/pytest/core/test_session.py
@@ -0,0 +1,414 @@
+import time
+
+from django.urls import reverse
+import pytest
+
+from benefits.core import models, session
+
+
+@pytest.mark.django_db
+def test_active_agency_False(app_request):
+    session.update(app_request, agency=None)
+
+    assert not session.active_agency(app_request)
+
+    agency = models.TransitAgency.objects.first()
+    agency.active = False
+    agency.save()
+
+    session.update(app_request, agency=agency)
+
+    assert not session.active_agency(app_request)
+
+
+@pytest.mark.django_db
+def test_active_agency_True(app_request):
+    agency = models.TransitAgency.objects.filter(active=True).first()
+    session.update(app_request, agency=agency)
+
+    assert session.active_agency(app_request)
+
+
+@pytest.mark.django_db
+def test_debug_default(app_request):
+    debug = session.debug(app_request)
+
+    assert isinstance(debug, bool)
+    assert not debug
+
+
+@pytest.mark.django_db
+def test_did(app_request):
+    did = session.did(app_request)
+
+    assert did
+    assert isinstance(did, str)
+
+
+@pytest.mark.django_db
+def test_eligibility_default(app_request):
+    assert session.eligibility(app_request) is None
+
+
+@pytest.mark.django_db
+def test_eligibile_False(app_request):
+    agency = models.TransitAgency.objects.first()
+    eligibility = []
+
+    session.update(app_request, agency=agency, eligibility_types=eligibility)
+
+    assert not session.eligible(app_request)
+
+
+@pytest.mark.django_db
+def test_eligibile_True(app_request):
+    agency = models.TransitAgency.objects.first()
+    eligibility = agency.eligibility_types.first()
+
+    session.update(app_request, agency=agency, eligibility_types=[eligibility.name])
+
+    assert session.eligible(app_request)
+
+
+@pytest.mark.django_db
+def test_enrollment_token_default(app_request):
+    assert session.enrollment_token(app_request) is None
+    assert session.enrollment_token_expiry(app_request) is None
+
+
+@pytest.mark.django_db
+def test_enrollment_token_valid_expired(app_request):
+    # valid token expired in the far past
+    token = "token"
+    exp = time.time() - 10000
+
+    session.update(app_request, enrollment_token=token, enrollment_token_exp=exp)
+
+    assert not session.enrollment_token_valid(app_request)
+
+
+@pytest.mark.django_db
+def test_enrollment_token_valid_invalid(app_request):
+    # invalid token expiring in the far future
+    token = None
+    exp = time.time() + 10000
+
+    session.update(app_request, enrollment_token=token, enrollment_token_exp=exp)
+
+    assert not session.enrollment_token_valid(app_request)
+
+
+@pytest.mark.django_db
+def test_enrollment_token_valid_valid(app_request):
+    # valid token expiring in the far future
+    token = "token"
+    exp = time.time() + 10000
+
+    session.update(app_request, enrollment_token=token, enrollment_token_exp=exp)
+
+    assert session.enrollment_token_valid(app_request)
+
+
+@pytest.mark.django_db
+def test_language_default(app_request):
+    assert session.language(app_request) == "en"
+
+
+@pytest.mark.django_db
+def test_language_es(app_request):
+    app_request.LANGUAGE_CODE = "es"
+    assert session.language(app_request) == "es"
+
+
+@pytest.mark.django_db
+def test_logged_in_default(app_request):
+    assert not session.logged_in(app_request)
+
+
+@pytest.mark.django_db
+def test_logged_in_False(app_request):
+    session.update(app_request, oauth_token=False)
+
+    assert not session.logged_in(app_request)
+
+
+@pytest.mark.django_db
+def test_logged_in_True(app_request):
+    session.update(app_request, oauth_token=True)
+
+    assert session.logged_in(app_request)
+
+
+@pytest.mark.django_db
+def test_logout(app_request):
+    session.update(app_request, oauth_token="oauth_token", enrollment_token="enrollment_token")
+    assert session.logged_in(app_request)
+
+    session.logout(app_request)
+
+    assert not session.logged_in(app_request)
+    assert not session.enrollment_token(app_request)
+    assert not session.oauth_token(app_request)
+
+
+@pytest.mark.django_db
+def test_oauth_token_default(app_request):
+    assert not session.oauth_token(app_request)
+
+
+@pytest.mark.django_db
+def test_rate_limit_counter_default(app_request):
+    assert session.rate_limit_counter(app_request) == 0
+
+
+@pytest.mark.django_db
+def test_rate_limit_counter_increment(app_request):
+    session.reset_rate_limit(app_request)
+    session.increment_rate_limit_counter(app_request)
+
+    c1 = session.rate_limit_counter(app_request)
+    assert isinstance(c1, int)
+    assert c1 > 0
+
+    session.increment_rate_limit_counter(app_request)
+
+    c2 = session.rate_limit_counter(app_request)
+    assert c2 == c1 + 1
+
+
+@pytest.mark.django_db
+def test_rate_limit_reset(mocker, app_request):
+    mocker.patch.object(session.settings, "RATE_LIMIT_PERIOD", 100)
+    t0 = int(time.time())
+
+    session.reset_rate_limit(app_request)
+
+    assert session.rate_limit_counter(app_request) == 0
+    assert session.rate_limit_time(app_request) >= t0 + 100
+
+
+@pytest.mark.django_db
+def test_rate_limit_time_default(app_request):
+    # a reset session also resets rate limit time (t0), which should have already happened coming into this test
+    t0 = session.rate_limit_time(app_request)
+    t1 = int(time.time())
+
+    assert t1 >= t0
+
+
+@pytest.mark.django_db
+def test_reset_agency(app_request):
+    agency = models.TransitAgency.objects.first()
+    session.update(app_request, agency=agency)
+
+    assert session.agency(app_request)
+
+    session.reset(app_request)
+
+    assert session.agency(app_request) is None
+
+
+@pytest.mark.django_db
+def test_reset_eligibility(app_request):
+    app_request.session[session._ELIGIBILITY] = ["type1"]
+
+    session.reset(app_request)
+
+    assert session.eligibility(app_request) is None
+
+
+@pytest.mark.django_db
+def test_reset_enrollment(app_request):
+    app_request.session[session._ENROLLMENT_TOKEN] = "enrollmenttoken123"
+    app_request.session[session._ENROLLMENT_TOKEN_EXP] = "1234567890"
+
+    session.reset(app_request)
+
+    assert session.enrollment_token(app_request) is None
+    assert session.enrollment_token_expiry(app_request) is None
+    assert not session.enrollment_token_valid(app_request)
+
+
+@pytest.mark.django_db
+def test_reset_oauth(app_request):
+    app_request.session[session._OAUTH_TOKEN] = "oauthtoken456"
+
+    session.reset(app_request)
+
+    assert session.oauth_token(app_request) is None
+
+
+@pytest.mark.django_db
+def test_reset_origin(app_request):
+    app_request.session[session._ORIGIN] = "/origin"
+
+    session.reset(app_request)
+
+    assert session.origin(app_request) == reverse("core:index")
+
+
+@pytest.mark.django_db
+def test_reset_start(app_request):
+    # start shouldn't change between resets
+    start = session.start(app_request)
+
+    session.reset(app_request)
+
+    assert session.start(app_request) == start
+
+
+@pytest.mark.django_db
+def test_reset_verifier(app_request):
+    app_request.session[session._VERIFIER] = "verifier"
+
+    session.reset(app_request)
+
+    assert session.verifier(app_request) is None
+
+
+@pytest.mark.django_db
+def test_reset_user(app_request):
+    # subsequent reset should not overwrite these
+    uid = session.uid(app_request)
+    did = session.did(app_request)
+
+    assert uid
+    assert did
+
+    session.reset(app_request)
+
+    assert session.uid(app_request) == uid
+    assert session.did(app_request) == did
+
+
+@pytest.mark.django_db
+def test_start_default(app_request):
+    t0 = time.time()
+    start = session.start(app_request)
+
+    assert isinstance(start, int)
+    assert start >= t0
+
+
+@pytest.mark.django_db
+def test_uid(app_request):
+    uid = session.uid(app_request)
+
+    assert uid
+    assert isinstance(uid, str)
+
+
+@pytest.mark.django_db
+def test_update_agency_None(app_request):
+    session.update(app_request, agency=None)
+
+    assert session.agency(app_request) is None
+
+
+@pytest.mark.django_db
+def test_update_agency_str(app_request):
+    session.update(app_request, agency="abc")
+
+    assert session.agency(app_request) is None
+
+
+@pytest.mark.django_db
+def test_update_agency_int(app_request):
+    session.update(app_request, agency=1)
+
+    assert session.agency(app_request) is None
+
+
+@pytest.mark.django_db
+def test_update_agency_TransitAgency(app_request):
+    agency = models.TransitAgency.objects.first()
+
+    session.update(app_request, agency=agency)
+
+    assert session.agency(app_request) == agency
+
+
+@pytest.mark.django_db
+def test_update_debug_None(app_request):
+    debug = session.debug(app_request)
+    session.update(app_request, debug=None)
+
+    assert session.debug(app_request) == debug
+
+
+@pytest.mark.django_db
+def test_update_debug_False(app_request):
+    session.update(app_request, debug=False)
+
+    assert not session.debug(app_request)
+
+
+@pytest.mark.django_db
+def test_update_debug_True(app_request):
+    session.update(app_request, debug=True)
+
+    assert session.debug(app_request)
+
+
+@pytest.mark.django_db
+def test_update_eligibility_empty(app_request):
+    session.update(app_request, eligibility_types=[])
+
+    assert session.eligibility(app_request) is None
+
+
+@pytest.mark.django_db
+def test_update_eligibility_many(app_request):
+    eligiblity = models.EligibilityType.objects.all()
+
+    with pytest.raises(NotImplementedError):
+        assert session.update(app_request, eligibility_types=[e.name for e in eligiblity])
+
+
+@pytest.mark.django_db
+def test_update_eligibility_single(app_request):
+    agency = models.TransitAgency.objects.first()
+    eligibility = agency.eligibility_types.first()
+
+    session.update(app_request, agency=agency, eligibility_types=[eligibility.name])
+
+    assert session.eligibility(app_request) == eligibility
+
+
+@pytest.mark.django_db
+def test_update_enrollment_token(app_request):
+    token = "token"
+    exp = 1234567890
+
+    session.update(app_request, enrollment_token=token, enrollment_token_exp=exp)
+
+    assert session.enrollment_token(app_request) == token
+    assert session.enrollment_token_expiry(app_request) == exp
+
+
+@pytest.mark.django_db
+def test_update_oauth_token(app_request):
+    session.update(app_request, oauth_token="token")
+
+    assert session.oauth_token(app_request) == "token"
+
+
+@pytest.mark.django_db
+def test_update_origin(app_request):
+    session.update(app_request, origin="/origin")
+
+    assert session.origin(app_request) == "/origin"
+
+
+@pytest.mark.django_db
+def test_update_verifier(app_request):
+    verifier = models.EligibilityVerifier.objects.first()
+
+    session.update(app_request, verifier=verifier)
+
+    assert session.verifier(app_request) == verifier
+
+
+@pytest.mark.django_db
+def test_verifier_default(app_request):
+    assert session.verifier(app_request) is None
diff --git a/tests/pytest/test_settings.py b/tests/pytest/core/test_settings.py
similarity index 100%
rename from tests/pytest/test_settings.py
rename to tests/pytest/core/test_settings.py
diff --git a/tests/pytest/test_views.py b/tests/pytest/core/test_views.py
similarity index 100%
rename from tests/pytest/test_views.py
rename to tests/pytest/core/test_views.py
diff --git a/tests/pytest/oauth/test_views.py b/tests/pytest/oauth/test_views.py
--- a/tests/pytest/oauth/test_views.py
+++ b/tests/pytest/oauth/test_views.py
@@ -18,49 +18,49 @@
 
 
 @pytest.mark.request_path("/oauth/login")
-def test_login(mocker, session_request):
+def test_login(mocker, app_request):
     mock_client = mocker.patch.object(benefits.oauth.views, "oauth_client")
     mock_client.authorize_redirect.return_value = HttpResponse("authorize redirect")
 
-    assert not session.logged_in(session_request)
+    assert not session.logged_in(app_request)
 
-    login(session_request)
+    login(app_request)
 
-    mock_client.authorize_redirect.assert_called_with(session_request, "https://testserver/oauth/authorize")
-    assert not session.logged_in(session_request)
+    mock_client.authorize_redirect.assert_called_with(app_request, "https://testserver/oauth/authorize")
+    assert not session.logged_in(app_request)
 
 
 @pytest.mark.request_path("/oauth/login")
-def test_authorize_fail(mocker, session_request):
+def test_authorize_fail(mocker, app_request):
     mock_client = mocker.patch.object(benefits.oauth.views, "oauth_client")
     mock_client.authorize_access_token.return_value = None
 
-    assert not session.logged_in(session_request)
+    assert not session.logged_in(app_request)
 
-    result = authorize(session_request)
+    result = authorize(app_request)
 
-    mock_client.authorize_access_token.assert_called_with(session_request)
-    assert not session.logged_in(session_request)
+    mock_client.authorize_access_token.assert_called_with(app_request)
+    assert not session.logged_in(app_request)
     assert result.status_code == 302
     assert result.url == reverse(ROUTE_START)
 
 
 @pytest.mark.request_path("/oauth/login")
-def test_authorize_success(mocker, session_request):
+def test_authorize_success(mocker, app_request):
     mock_client = mocker.patch.object(benefits.oauth.views, "oauth_client")
     mock_client.authorize_access_token.return_value = {"id_token": "token"}
 
-    result = authorize(session_request)
+    result = authorize(app_request)
 
-    mock_client.authorize_access_token.assert_called_with(session_request)
-    assert session.logged_in(session_request)
-    assert session.oauth_token(session_request) == "token"
+    mock_client.authorize_access_token.assert_called_with(app_request)
+    assert session.logged_in(app_request)
+    assert session.oauth_token(app_request) == "token"
     assert result.status_code == 302
     assert result.url == reverse(ROUTE_CONFIRM)
 
 
 @pytest.mark.request_path("/oauth/logout")
-def test_logout(mocker, session_request):
+def test_logout(mocker, app_request):
     # logout internally calls _deauthorize_redirect
     # this mocks that function and a success response
     # and returns a spy object we can use to validate calls
@@ -68,24 +68,24 @@ def test_logout(mocker, session_request):
     spy = mocker.patch("benefits.oauth.views._deauthorize_redirect", return_value=HttpResponse(message))
 
     token = "token"
-    session.update(session_request, oauth_token=token)
-    assert session.oauth_token(session_request) == token
+    session.update(app_request, oauth_token=token)
+    assert session.oauth_token(app_request) == token
 
-    result = logout(session_request)
+    result = logout(app_request)
 
     spy.assert_called_with(token, "https://testserver/oauth/post_logout")
     assert result.status_code == 200
     assert message in str(result.content)
-    assert not session.logged_in(session_request)
-    assert session.enrollment_token(session_request) is False
+    assert not session.logged_in(app_request)
+    assert session.enrollment_token(app_request) is False
 
 
 @pytest.mark.request_path("/oauth/post_logout")
-def test_post_logout(session_request):
+def test_post_logout(app_request):
     origin = reverse("core:index")
-    session.update(session_request, origin=origin)
+    session.update(app_request, origin=origin)
 
-    result = post_logout(session_request)
+    result = post_logout(app_request)
 
     assert result.status_code == 302
     assert result.url == origin
diff --git a/tests/pytest/run.sh b/tests/pytest/run.sh
new file mode 100755
--- /dev/null
+++ b/tests/pytest/run.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+set -eu
+
+pytest --cov=benefits --cov-branch
+
+coverage html --directory benefits/static/coverage
+
+echo
+echo "Launch the app and visit http://localhost:${DJANGO_LOCAL_PORT}/static/coverage/index.html"
diff --git a/tests/pytest/test_session.py b/tests/pytest/test_session.py
deleted file mode 100644
--- a/tests/pytest/test_session.py
+++ /dev/null
@@ -1,14 +0,0 @@
-import pytest
-
-from benefits.core import session
-
-
-@pytest.mark.django_db
-def test_reset(rf):
-    request = rf.get("/")
-
-    request.session = {session._AGENCY: "abc"}
-
-    session.reset(request)
-
-    assert session.agency(request) is None