Skip to main content

How to Test the JCPC Authentication with Pytest and Playwright?


A reliable booking system starts with reliable authentication. In this tutorial, we’ll use Pytest and Playwright to test the Jersey City Pickleball Club (JCPC) website’s essential user flows—sign up, sign in, and sign out. 
Rather than simply checking whether a page loads, we’ll simulate real user interactions and verify that JCPC responds correctly at each step. By the end, you’ll have a practical foundation for building end-to-end authentication tests for a Flask application.

Prerequisite:

This tutorial is part of the Flask Landing Page and Reservation System Series.


📚 View the Complete Flask Landing Page and Reservation System Series

⬅ Previous Part

Preliminary:
Before I begin, please activate the virtual environment and install the required dependencies.
python -m venv venv
venv\Scripts\activate
pip install pytest playwright
Then, we need to set up the file and folder structure as below:

Even though the files above include a variety of tests for both Pytest and end-to-end testing. In this tutorial, I will focus on authentication testing, including sign-in, sign-up, and sign-out.

Before I begin, let us define the following:
What are Pytest and Playwright?
Pytest—a testing framework for Python designed to write small, readable tests that scale to handle complex functional testing for applications and libraries. Using Pytest with a Flask application simplifies writing, organising, and scaling test suites. Therefore, it is normal to test the backend Python script.

PlaywrightDeveloped by Microsoft, Playwright is a modern browser automation library. It interacts directly with web apps to simulate user behaviour. Therefore, it is used to test the HTML frontend code.

What is Conftest.py? What does it do?
Conftest.pyfile that serves as a local configuration file containing fixtures and configuration hooks that can be shared across multiple test files within that directory and its subdirectories

Therefore, I will use both testing libraries for Jersey City Pickleball Club. 
To run Pytest, I simply input the following command in the terminal.
pytest tests/test_auth.py -v
Meanwhile, to run Playwright, I need to open two terminals.
i) One is running the Flask app
Python app.py
ii) The other one is running the Pytest
Pytest tests/e2e/test...py -v

Step 1: Set up the conftest.py for Pytest
import pytest
from werkzeug.security import generate_password_hash
from tinydb import Query
from app import app, users_table, bookings_table
from user import User
from flask_login import login_user
from werkzeug.security import generate_password_hash

UserQuery = Query()

@pytest.fixture
def client():
    app.config["TESTING"] = True
    app.config["WTF_CSRF_ENABLED"] = False
    
    with app.test_client() as client:
        yield client
1. The client fixture

  • This tells Pytest: "Create a reusable fixture called client."

2. Configure Flask for testing

  • This tells Flask, "This application is running in testing mode."
  • This is important because Flask behaves slightly differently when testing.
  • For example, exceptions can be propagated to the test instead of being hidden behind a normal error page.
3. Disable CSRF

  • If my forms use Flask-WTF, they may normally require a CSRF token.
  • But when you're testing, I'll necessarily provide that token.
  • Therefore, it allows my test to submit the form without CSRF validation getting in the way.
4. Create Flask's test client

  • Flask provides a special test client that lets you make HTTP requests to my application without starting the Flask development server.
-----------------------------------------------------------------------------------------------------------------------------------------------------
@pytest.fixture
def user():
    doc_id = users_table.insert({
        "username": "kelvin",
        "email": "kelvin@example.com",
        "password_hash": generate_password_hash("password123")})

    yield doc_id
    users_table.remove(doc_ids=[doc_id])
    user_data = users_table.get(
        UserQuery.username == "kelvin")
        
    if user_data:
        users_table.remove(doc_ids=[user_data.doc_id])
1. Define the 
user fixture

  • This creates a Pytest fixture called user.
  • Pytest sees user and automatically runs the fixture before the test.
2. Insert a test user into TinyDB
  • This creates a user in my TinyDB users_table.
  • The resulting record is approximately
    • username:      kelvin
    • email:         kelvin@example.com
    • password_hash: <hashed version of password123>
  • Notice that you're not storing password123 directly. Instead, it generates a password hash.
  • That's important because my actual application probably expects the database to contain a hashed password
3. yield doc_id

  • It means "Give doc_id to the test, then pause the fixture here."
  • The value of the document ID user will be 15 if TinyDB assigned document ID 15.

4. First cleanup
  • After the test finishes, Python continues and removes the exact document that the fixture created. This is good test isolation.
5. My second cleanup
  • This searches TinyDB for a user whose username is "kelvin". Conceptually: If it finds one, user_data contains that TinyDB document, which contains its TinyDB document ID.
6. Check whether the user exists
  • This means "if a user named kelvin was found..." then delete that user.
-----------------------------------------------------------------------------------------------------------------------------------------------------
@pytest.fixture
def authenticated_client(client, user):
    response = client.post(
        "/signin",
        data={
            "username": "kelvin",
            "password": "password123"
        }
    )
    assert response.status_code == 200
    assert response.headers["HX-Redirect"] == "/dashboard"

    return client
1. @pytest.fixture 
  • This tells Pytest: "The function immediately below is a fixture that other tests can use."
  • Therefore, Pytest will automatically execute authenticated_client() first.
2. def authenticated_client(client, user):
  • Two fixtures are being requested here:
    • The client is normally my Flask test client, probably defined in conftest.py. It allows me to simulate browser requests without actually opening a browser.
    • The user is a fixture is important because it probably creates my test user.
3. client.post("/signin", ...)
  • I'll simulate the user submitting my sign-in form. It's equivalent to a browser doing the following:
    • POST /signin
    • username = kelvin
    • password = password123
  • The test client sends the POST request directly to that route.
4. Why "password123" works
  • The database, therefore, stores something like "password_hash: scrypt:..", but it doesn't store "password: password123".
  • When my sign-in route receives: "password123". Flask checks it against the stored hash.

5. response.status_code == 200
  • This checks that the sign-in request was successful from the HTTP perspective.
  • In my application, because I am using HTMX, my /signin route apparently returns a successful 200 response containing an HTMX redirect header.
6. response.headers["HX-Redirect"]
  • It checks that after successful authentication, Flask tells HTMX: Go to /dashboard
  • So I am testing two things:
    • First, the sign-in request succeeded.
    • Second, a successful sign-in redirects the user to the dashboard.
7. return client
  • This is what makes the authenticated Flask client available to the actual test.
-----------------------------------------------------------------------------------------------------------------------------------------------------

Because of autouse=True, Pytest effectively does this:
@pytest.fixture(autouse=True)
def clean_bookings():
    bookings_table.truncate()
    yield
    bookings_table.truncate()

1. @pytest.fixture(autouse=True)
  • The fixture tells Pytest that this is a fixture.
  • It means run this fixture automatically for every test in its scope, even if the test doesn't explicitly request it.
  • Pytest will still run clean_bookings.
2. bookings_table.truncate().
  • This removes all records from the bookings table. This gives my test a clean database.
3. yield
  • Basically means "Pause the fixture here and let the test run. When the test finishes, continue executing the fixture."
4. Why clean both before AND after?
  • Even though I could technically just clean before the test, cleaning both before and after is safer.
  • Suppose I create Test 1 of a booking; then the database still contains Kelvin's booking. Therefore, the second truncate() removes it.

Step 2: Test the signup, sign-in, and sign-out with Pytest
(i) Signin

def test_valid_signin(client, user):
    response = client.post(
        "/signin",
        data={
            "username": "kelvin",
            "password": "password123"
        })
    assert response.status_code == 200
    assert response.headers["HX-Redirect"] == "/dashboard"
1. The test function 
  • This defines a Pytest test called test_valid_signin. The "client" is usually a Flask test client fixture. "user" is another fixture that probably creates a test user in my database.
  • For example, my user fixture might create the following:
    • username = kelvin
    • password = password123
  • So the test has a known user that it can authenticate.
2. Send a POST request
This simulates a user submitting the sign-in form.
  • It is essentially testing something similar to the following:
    • POST /signin 
    • username=kelvin 
    • password=password123
  • The important point is that no real browser is involved.
  • Pytest is directly calling my Flask application through the test client.
3. The response contains Flask's response
  • The response contains what my Flask /signin route returned.
  • The test can then inspect that response.
4. Check the HTTP status code

  • This means, "I expect the /signin request to return HTTP 200."
  • HTTP 200 means the request was successfully processed.
  • So if my application accidentally returned:
    • 401 Unauthorised
    • 400 Bad Request
    • 500 Internal Server Error
-----------------------------------------------------------------------------------------------------------------------------------------------------

def test_invalid_password(client, user):
    response = client.post(
        "/signin",
        data={
            "username": "kelvin",
            "password": "wrong-password"
        })

    assert response.status_code == 200
    assert b"Invalid username or password." in response.data
1. def test_invalid_password(client, user):
  •    Similar to above
2. Send a POST request to /signin
  •    Similar to above
3. Why is the password deliberately wrong?
  • The test user was probably created with: password123
  • But my test submits "wrong-password." Therefore, I am testing the negative authentication path.
4. response.status_code == 200
  • My application apparently handles an invalid login by returning the sign-in page/form again with an error message, rather than returning an HTTP 401.
  • Therefore, 200 is correct for how my route is currently implemented.
5. response.data
  • This is the HTML/body returned by Flask. However, the "response.data" is returned as bytes, not a normal Python string. That's why my assertion uses b.
-----------------------------------------------------------------------------------------------------------------------------------------------------
def test_unknown_username(client):
    response = client.post(
        "/signin",
        data={
            "username": "unknown_user",
            "password": "password123"
        }
    )

    assert response.status_code == 200
    assert b"Invalid username or password." in response.data
1. def test_invalid_password(client, user):
  •    Similar to above
2. Send a POST request to /signin
  •    Similar to above
3. Why don't we use user?
  • There is no unknown_user in the database. So I am testing a non-existent username.
4Assert response.status_code == 200
  •    Similar to above
5. Check the error message
  • Similar to above
-----------------------------------------------------------------------------------------------------------------------------------------------------
(ii) Signup
def test_signup_new_user(client):
    response = client.post(
        "/signup",
        data={
            "username": "newuser2",
            "email": "newuser@example.com",
            "password": "password123"
        }
    )
    assert response.status_code == 200
    assert b"Account created successfully!" in response.data
1. def test_signup_new_user(client):
  • Similar to above
2. Send a POST request to /signup
  • Similar to above
3. Provide the form data
  • This represents what the user entered into the signup form.
    • username - newuser2
    • email - newuser@example.com
    • password - password123
  • Basically saying, "Pretend a user submitted the signup form with these values."

4. Store the server's response
  • After Flask processes the request, it returns a response.
5. Check the HTTP status code
  • This checks that Flask returned HTTP status 200 OK.
  • In other words, "Did the signup request complete successfully?" If Flask returned 200, the assertion passes. Otherwise, the test fails.
6. Check the success message
  • Similar to above
-----------------------------------------------------------------------------------------------------------------------------------------------------
def test_signup_existing_username(client, user):
    response = client.post(
        "/signup",
        data={
            "username": "kelvin",
            "email": "another@example.com",
            "password": "password123"
        }
    )

    assert response.status_code == 200
    assert b"Username already exists." in response.data
1. def test_signup_existing_username(client, user):
  • There are two fixtures here:
    • The client gives me the Flask test client so I can make requests.
    • The user is important because my user fixture probably creates a test user.
  • Even though before this test starts, "kelvin" already exists, it will allow you to test the duplicate-username situation.
2.  The submitted username is "kelvin."
  • My user fixture has already created "Kelvin." However, the email is different: "email": "another@example.com", and this is intentional.
  • I am specifically testing what happens when the username is duplicated, even though the email is different.

3. Check the status code

  • This means, "Did the signup page respond normally?"
  • So 200 doesn't necessarily mean the signup succeeded. However, it means the HTTP request was successfully handled.
4. Check the error message

  • Similar to above

-----------------------------------------------------------------------------------------------------------------------------------------------------

(iii) Sign out

def test_signout(client, user):
    with client.session_transaction() as session:
        session["_user_id"] = str(user)

    response = client.get("/signout")

    assert response.status_code == 302
    assert response.location.endswith("/")
1. def test_signout(client, user):
  • Similar to above
2. Open the Flask session
  • Flask-Login normally stores information about the logged-in user in the Flask session. Therefore, session_transaction() allows my test to access and modify the session.
  • "Before I make my request, let me manually set the session so Flask thinks this user is logged in."
3. Set the logged-in user's ID

  • This tells Flask-Login: "The current logged-in user is this user."
  • Why str()? Because Flask-Login stores the user ID in the session as a string; converting it to a string makes the test match Flask-Login's expected format.
4. Send a request to /signout

  • Because we manually established the session first, Flask-Login should see the user as authenticated.
  • The important operation is logout_user(), which removes the user's authentication state.
5. Check for a redirect

  • 302 means "Found / Redirect"
  • Therefore, Flask doesn't return the homepage directly. So this assertion checks that the signout route redirected the user.

6. Check where the user was redirected

  •  The redirect destination might contain:  / or http://localhost/
  • It means, "I don't care about the domain or hostname; I only want to verify that the redirect ends at /."
---------------------------------------------------------------------------------------------------------------------------------------------------

🎁 Get Your FREE Flask Cheat Sheet

Get your FREE Flask Cheat Sheet

Get more Flask, Python automation, Docker, and HTMX tutorials delivered to your inbox.

✓ Practical coding tutorials
✓ Automation tips for SMEs
✓ New project ideas and templates

Download my FREE Flask Cheat Sheet (PDF)


---------------------------------------------------------------------------------------------------------------------------------------------------
Step 3: Set up the conftest.py for Playwright
import pytest
from werkzeug.security import generate_password_hash
from tinydb import TinyDB, Query
from app import users_table

UserQuery = Query()
db = TinyDB("database.json")

@pytest.fixture
def authenticated_page(page):
    existing_user = users_table.get(
        UserQuery.username == "kelvin"
    )

    if not existing_user:
        users_table.insert({
            "username": "kelvin",
            "email": "kelvin@example.com",
            "password_hash": generate_password_hash("password123")
        })

    page.goto("/signin")
    page.get_by_label("Username").fill("kelvin")
    page.get_by_label("Password").fill("password123")
    page.get_by_role("button", name="Sign In").click()
    page.wait_for_timeout(2000)
    return page
1. The fixture
  • @pytest.fixture tells Pytest that this function is a fixture.
  • authenticated_page is the fixture name.
  • page is the Playwright page fixture.
2. Check whether the test user already exists
  • This searches myTinyDB users_table for a user whose username is "kelvin".
  • This is important because I don't want to insert the same user every time the fixture runs.
3. Create the user if necessary
  • If "kelvin" doesn't exist, the fixture creates the user, and the password is hashed before being stored.
  • This is the correct approach because my application should compare the entered password against the hash rather than storing the plain password.
4. Open the sign-in page
  • Playwright navigates to: /signin
5. Fill in the username
  • Playwright finds the form field associated with the label: Username,  and enters: kelvin
  • because get_by_label() interacts with the form based on its accessible label.
6. Fill in the password
  • It finds the field labelled Password and enters: password123
7. Click Sign In

  • This finds a button with the accessible name "Sign In."
-----------------------------------------------------------------------------------------------------------------------------------------------------

@pytest.fixture
def bookings_table():
    return db.table("bookings")
1. @pytest.fixture
  • Similar to above
2. Define the fixture
  • Similar to above
3. Get the TinyDB table
  • This accesses the TinyDB table named: bookings
  • db.table("bookings") gives you a TinyDB Table object that allows me to work with the bookings data.
-----------------------------------------------------------------------------------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def clean_bookings(bookings_table):
    bookings_table.truncate()
    yield
    bookings_table.truncate()
This function is exactly similar to a conftest file in pytest; please refer to the above.

Step 4: Test the signup, sign-in, and sign-out with Playwright
(i) Signin
import re
from playwright.sync_api import Page, expect

BASE_URL = "http://127.0.0.1:5000"
def test_signin_page_loads(page: Page): page.goto(f"{BASE_URL}/") expect(page).to_have_title("User-Login | AppNew Sign In") signin_tab = page.locator( "a.nav-link", has_text="Sign In" ) signup_tab = page.locator( "a.nav-link", has_text="Sign Up" ) expect(signin_tab).to_be_visible() expect(signup_tab).to_be_visible() expect( page.locator("input[name='username']") ).to_be_visible() expect( page.locator("input[name='password']") ).to_be_visible() expect( page.locator("input[type='submit']") ).to_be_visible()
1. Define the test
  • Similar to above
2. Open the website
  • This tells Playwright to navigate to my application's home page. BASE_URL = "http://127.0.0.1:5000"
3. Check the page title
  • This verifies that the browser page has exactly this title: User-Login | AppNew Sign In
  • If the actual title is different, the test fails. This is useful because it confirms that the expected page/template has loaded.

4. Find the Sign-In tab

  • This creates a locator for an HTML <a> element. means: Find an <a> element whose class is nav-link.
  • The second argument means "Among those elements, find the one containing the text Sign In."
5. Find the Sign-Up tab
  • This works exactly the same way. Again, this only creates the locator.
6. Check that Sign In/Sign Up is visible
  • This verifies that the Sign In tab is actually visible in the browser.
  • If the element exists in the HTML but is hidden with CSS, this assertion can fail.
7. Check the username field

  • It means: Find an <input> whose name attribute is username.
  • Then: checks that the username input is visible.
8. Check the password field

  • The test confirms that the password field is visible to the user. 
9. Check the submit button

  • This searches for an <input> whose type is submit, and the test verifies that the Sign In button is visible.
-----------------------------------------------------------------------------------------------------------------------------------------------------

def test_invalid_signin(page: Page):
    page.goto(f"{BASE_URL}/")

    page.locator(
        "input[name='username']"
    ).fill("invalid_user")

    page.locator(
        "input[name='password']"
    ).fill("wrong_password")

    page.locator(
        "form input[type='submit']"
    ).click()

    alert = page.locator(".alert")
    expect(alert).to_be_visible()
1. Define the test
  • Similar to above
2. Open the application
  • Similar to above
3. Find the username field

  • This tells Playwright: Find an <input> element whose name attribute is username.
4. Enter an invalid username

  • means:Find the username field and type invalid_user into it.
5. Find the password field

  • Similar to above
6. Enter an incorrect password

means: Put wrong_password into the password field.

  • Now the form contains: Username: invalid_user and Password: *************** (The credentials are deliberately wrong.)

7. Find the submit button

  • It means: Find an <input> with type="submit" that is inside a <form>.

8. Click the submit button

  • This simulates the user clicking Sign In.
-----------------------------------------------------------------------------------------------------------------------------------------------------

(ii) Signup

def test_signup_empty_form(page: Page):
    open_signup_form(page)

    submit = page.locator(
        "form input[type='submit']"
    )
    submit.click()
1. Define the test
  • Similar to above
2. Open the Sign Up form

  • This is a helper function that you created elsewhere.
  • Instead of repeating several Playwright commands every time you want to test signup, you put them inside open_signup_form().
  • means:"Prepare the browser so that the Sign Up form is open."
3.  Find the submit button

  • means: Find a submit input inside a form. So submit is now a Playwright locator pointing to the Sign Up button.

4. Click the button

  • Similar to above
-----------------------------------------------------------------------------------------------------------------------------------------------------

def test_signup_invalid_data(page: Page):
    open_signup_form(page)

    page.locator(
        "input[name='username']"
    ).fill("invalid_test_user")

    page.locator(
        "input[name='email']"
    ).fill("invalid@example.com")

    page.locator(
        "input[name='password']"
    ).fill("123")

    page.locator(
        "form input[type='submit']"
    ).click()

    page.wait_for_load_state("networkidle")

    expect(
        page.locator("#form-container")
    ).to_be_visible()
1. Define the test
  • Similar to above
2. Open the Sign Up form
  • Similar to above
3. Fill in the username
  • Playwright finds: "username", and enters:invalid_test_user
4. Fill in the email 
  • This finds: "email". Notice that this email is actually syntactically valid as an email address.
  • So whether this is considered "invalid data" depends on my application's validation rules.
5. Fill in an invalid password
  • This finds the password field: "password" and enters: 123
  • This is probably invalid because my application likely requires a password longer than 3 characters.
6. Submit the form
  • Similar to above
7. Wait for the page/network activity
  • This tells Playwright to wait until the page reaches the network idle state.
  • This can be useful when my application uses Flask + HTMX, because HTMX can make an asynchronous request after the button is clicked.
8. Find the form container
  • The # means you're looking for an element by its ID.
9. Verify that the container is visible

  • It says:"After submitting invalid signup data, I expect the authentication form container to still be visible."
  • If the application properly handles the invalid data and keeps the user on the signup/authentication UI, this assertion passes.
----------------------------------------------------------------------------------------------------------------------------------------------------

def test_signout(page: Page):
    signout = page.get_by_text("Sign Out", exact=True)
    expect(signout).to_be_visible()
    signout.click()
    expect(page).to_have_url(f"{BASE_URL}/")
    expect(
        page.locator("input[name='username']")
    ).to_be_visible()
    expect(
        page.locator("input[name='password']")
    ).to_be_visible()
1. Define the test
  • Similar to above
2. Find the Sign Out link

  • This tells Playwright: Find an element containing exactly the text Sign Out
  • The result is stored in a Playwright locator.
3. Make sure Sign Out is visible

  • This verifies that the Sign Out link is actually visible to the user.
  • If the link doesn't exist or is hidden, the test fails here.

4. Click Sign Out

  • This simulates the user clicking the Sign Out link.
  • The browser sends a request to /signout.

5. Verify the redirect

  • This checks the browser's current URL. http://127.0.0.1:5000/
  • This confirms that the sign-out operation redirected the user to the expected page.

6. Check that the username field is visible

  • So I am verifying: "After signing out, can the user see the login form?"

7. Check that the password field is visible

  • This checks that the password field is also visible.
----------------------------------------------------------------------------------------------------------------------------------------------------

Step 5: Verify the result

Pytest:

tests/test_auth.py

(i) tests/e2e/signin.py


(ii) tests/e2e/signup.py

(iii) tests/e2e/signout.py

----------------------------------------------------------------------------------------------------------------------------------------------------
Final wrap-up:
In this tutorial, we used Pytest and Playwright to test the complete authentication flow of the Flask application, including sign-in page loading, invalid login credentials, empty and invalid signup forms, and user sign-out. Pytest provides the test structure and assertions, while Playwright simulates real browser interactions to verify that the correct forms, messages, redirects, and authentication states appear as expected. Together, they provide a practical way to ensure the authentication system works reliably from both the application and user's perspective.

Published: August 2026
Last Updated: August 2026

---------------------------------------------------------------------------------------------------------------------------------------------------

Thanks for reading! 

If you haven't subscribed yet, join my newsletter to receive future Python and Flask tutorials.


---------------------------------------------------------------------------------------------------------------------------------------------------
Need a similar system for your business? 
I build custom Flask web applications and Python automation solutions for SMEs and solopreneurs. 

---------------------------------------------------------------------------------------------------------------------------------------------------

About the Author 
Kelvin Loh is a Python developer focused on Flask, desktop applications, and business automation solutions. He shares practical tutorials and real-world coding projects to help developers and small businesses build useful applications.


Comments

Popular Posts

How to Build an Audiobook Workflow Desktop System with Python?

In this tutorial, we will build a simple audiobook player using Python and CustomTkinter. You will learn how to convert text into speech using gTTS and play it with PyGame. We will also implement play, pause, and stop controls, like those in a real audio player. By the end, you will have a clean and functional desktop audiobook app. Prerequisite: This tutorial is part of the standalone tutorial. 📚 View the standalone tutorial Preliminary   Before I begin, it is recommended to activate the virtual environment before installing the relevant dependencies. python -m venv venv venv\Scripts\activate pip install customtkinter pillow gTTS pygame CTkMessagebox pypdf Then, the following steps include setting up the file structure, app.py, and two additional folders: the uploads and media folders. The media folder contains the icons necessary to build the app; there are read, pause, and stop icons, as shown on the diagram. Step 1: Build up the app interface I have 4 sections here: the...

How to Set Up PgAdmin and Adminer Using Docker Compose?

If you are new to database management with Docker, this tutorial will guide you through setting up both PgAdmin and Adminer using Docker containers. By containerising these tools, you can quickly launch lightweight and portable database management environments without installing them directly on your operating system. This approach also makes it easier to manage configurations, updates, and multiple projects across different devices.