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.
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
Before I begin, please activate the virtual environment and install the required dependencies.
python -m venv venv
venv\Scripts\activate
pip install pytest playwrightThen, 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.
pytest tests/test_auth.py -v
Python app.pyPytest tests/e2e/test...py -v
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
- 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.
- 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.
- 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
userand automatically runs the fixture before the test.
- 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
- It means "Give
doc_idto the test, then pause the fixture here." - The value of the document ID user will be 15 if TinyDB assigned document ID 15.
- After the test finishes, Python continues and removes the exact document that the fixture created. This is good test isolation.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
200response containing an HTMX redirect header.
- 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.
- This is what makes the authenticated Flask client available to the actual test.
.png)
.png)
@pytest.fixture(autouse=True)
def clean_bookings():
bookings_table.truncate()
yield
bookings_table.truncate()- 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.
- This removes all records from the bookings table. This gives my test a clean database.
- Basically means "Pause the fixture here and let the test run. When the test finishes, continue executing the fixture."
- 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.
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
userfixture might create the following: - username = kelvin
- password = password123
- So the test has a known user that it can authenticate.
- 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.
- The response contains what my Flask /signin route returned.
- The test can then inspect that response.
- 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- Similar to above
- Similar to above
- The test user was probably created with: password123
- But my test submits "wrong-password." Therefore, I am testing the negative authentication path.
- 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,
200is correct for how my route is currently implemented.
- 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.data1. def test_invalid_password(client, user):- Similar to above
- Similar to above
- There is no unknown_user in the database. So I am testing a non-existent username.
- Similar to above
5. Check the error messageSimilar to above
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
- Similar to above
- 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."
- After Flask processes the request, it returns a response.
- 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.
- 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.
- 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.
- This means, "Did the signup page respond normally?"
- So
200doesn't necessarily mean the signup succeeded. However, it means the HTTP request was successfully handled.
- Similar to above
-----------------------------------------------------------------------------------------------------------------------------------------------------
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
- 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."
- 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.
/signoutBecause 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.
- 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 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)
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 page1. The fixture- @pytest.fixture tells Pytest that this function is a fixture.
- authenticated_page is the fixture name.
- page is the Playwright page fixture.
- This searches myTinyDB
users_tablefor a user whose username is"kelvin". - This is important because I don't want to insert the same user every time the fixture runs.
- 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.
- Playwright navigates to: /signin
- 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.
- It finds the field labelled Password and enters: password123
- 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
- Similar to above
- This accesses the TinyDB table named: bookings
db.table("bookings")gives you a TinyDBTableobject 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.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
- This tells Playwright to navigate to my application's home page. BASE_URL = "http://127.0.0.1:5000"
- 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 isnav-link. - The second argument means "Among those elements, find the one containing the text
Sign In."
- This works exactly the same way. Again, this only creates the locator.
- 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.
- It means: Find an
<input>whosenameattribute isusername. - Then: checks that the username input is visible.
- The test confirms that the password field is visible to the user.
- 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
- Similar to above
- This tells Playwright: Find an
<input>element whosenameattribute isusername.
- means:Find the username field and type
invalid_userinto it.
- Similar to above
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>withtype="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
- 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."
- means: Find a submit input inside a form. So
submitis 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
- Similar to above
- Playwright finds: "username", and enters:invalid_test_user
- 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.
- This finds the password field: "password" and enters: 123
- This is probably invalid because my application likely requires a password longer than 3 characters.
- Similar to above
- 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.
- The
#means you're looking for an element by its ID.
- 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
- This tells Playwright: Find an element containing exactly the text
Sign Out - The result is stored in a Playwright locator.
- 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
----------------------------------------------------------------------------------------------------------------------------------------------------
Published: August 2026
Last Updated: August 2026
---------------------------------------------------------------------------------------------------------------------------------------------------




















Comments