Hello from Japan 🇯🇵
I'm a truck driver in Japan, teaching myself web application development mainly with Python while continuing to work full-time.
This article is Stage 4 of my ongoing effort to strengthen pytest in a Flask application I'm building.
Instead of only adding more tests for normal behavior, Stage 4 focuses on questions like:
"What if the input is invalid?"
"What if the external AI service goes down?"
"What if authentication settings change after login?"
It is similar to the kind of hazard anticipation I use in my day job as a truck driver: thinking about what might happen before it becomes an accident.
This article was originally published in Japanese on Qiita and has been translated and adapted for DEV Community.
I used Codex to help with investigation and implementation.
Some of the internal behavior became complicated enough that there were points where my own understanding did not fully keep up.
So rather than pretending to explain internal details I don't completely understand, this article focuses on:
what I considered a problem, what I checked, and what the result was.
https://github.com/tosane932/sales_data_app
pytest Improvement Series
Stage 1
https://qiita.com/tosane932/items/f3de1e190873a90de39fStage 2
https://qiita.com/tosane932/items/b91261e7103df5792f7dStage 3
https://qiita.com/tosane932/items/6d1ca5490979c8cf9d62Stage 4
https://qiita.com/tosane932/items/372270330e73583a227fStage 5
https://qiita.com/tosane932/items/85fd24c7baa6fe7c76a7
Introduction
I've been gradually strengthening pytest in my personal Flask application.
The test suite originally had only three tests.
It grew through the previous stages:
- Stage 1: 3 → 9 tests
- Stage 2: 9 → 51 tests
- Stage 3: 51 → 69 tests
- additional demo seed tests: 69 → 71 tests
So at the beginning of Stage 4:
71 passed
By this point, I wanted to check more than:
"Does the application work correctly under normal conditions?"
I also wanted to ask:
"When something abnormal happens, does the application fail safely?"
In my work as a truck driver, I constantly think about things that might happen:
- someone might suddenly step or drive out
- cargo might shift or collapse
- a delivery mistake might happen
- a safety device might not work correctly
The idea is to anticipate danger before an accident occurs.
I approached Stage 4 of pytest in much the same way.
Instead of:
"Fix the accident after it happens."
I wanted to move toward:
"Find the places where an accident might happen before it does."
That became the theme of Stage 4.
Stage 4 Results
By the end of this stage:
71 tests
↓
87 tests
I added 16 tests.
Breakdown of the 16 New Tests
| Category | Added |
|---|---|
| Empty-database migration | 1 |
| Non-integer dashboard query parameters | 6 |
| Gemini failure fallbacks | 3 |
| Authenticated AI advice happy path | 1 |
| Fail-closed sessions | 2 |
| Invalid CSRF tokens | 3 |
| Total | 16 |
The final result was:
======================= 87 passed, 2 warnings in 15.65s ========================
1. Can Migrations Build the Database from Nothing?
The first thing I checked was the Alembic migration history.
Instead of testing only against a database that already existed, I wanted to verify:
"Can the current database schema be rebuilt from a completely empty database using only the migration history?"
The test creates an isolated SQLite database under tmp_path and runs:
Empty database
↓
Alembic base
↓
upgrade head
↓
current schema
The test verifies:
productsdaily_salesalembic_version- required columns
- the composite unique constraint on
daily_sales(product_id, date) - the database revision matches the current Alembic head
I did not hard-code the revision ID into the test.
Instead, the test dynamically checks the current Alembic head.
This test verifies that the migration chain can rebuild the expected schema in an isolated SQLite database. It does not replace PostgreSQL-specific migration testing.
If I compare this to a company, this isn't asking:
"Can the headquarters that already exists keep operating?"
It's asking:
"If we start from an empty lot, can we build a new branch correctly using only the blueprints?"
Result:
1 passed
2. Invalid year / month Should Return 400, Not 500
Next, I investigated dashboard-related query parameters.
The affected routes were:
/dashboard
/api/dashboard-data
/api/ai-advice
At the time, values such as:
?year=abc
or:
?month=abc
were passed directly to int().
That meant a ValueError could occur and potentially result in an HTTP 500 response.
So I defined the expected behavior as:
non-integer year or month values should be rejected with HTTP 400 Bad Request.
I added:
3 routes × year/month
= 6 cases
All six were RED at first:
Expected: 400
Actual: 500
I then added a small helper that converts query parameters to integers and calls abort(400) when the value is invalid.
Result:
6 passed
For the AI advice API, I also verified that invalid query parameters are rejected before the Gemini client is called.
3. Locking Down Gemini Failure Fallbacks
This application uses the Gemini API.
But Gemini is an external service, so failures can happen:
- 429
- 503
- other exceptions
The application already had fallback behavior for these cases.
Stage 4 added regression tests to preserve that behavior.
I tested:
429
→ fallback for rate limiting
503
→ fallback for service unavailable
other exception
→ general fallback
I did not connect to the real Gemini API.
All external calls were mocked.
These tests did not uncover a new bug.
Instead, the goal was:
to record an existing safety mechanism as regression tests so future changes don't accidentally remove it.
Result:
3 passed
In company terms, this is like asking:
"If one of our external business partners goes offline, does our own system stay standing?"
4. Testing the Authenticated AI Advice Happy Path
I also added one test for the normal AI advice flow.
I wanted to verify that the different parts of the system still worked together correctly when nothing was failing.
The flow was:
Authentication
↓
query processing
↓
SQLite database aggregation
↓
prompt generation
↓
mock Gemini
↓
JSON response
For example, suppose the database contains:
August 2026
Product A: 10
Product B: 5
The test verifies that:
- Product A and Product B for August are included in the prompt
- products from another month are not included
- the mocked Gemini response is returned as JSON
Result:
1 passed
5. Fail Closed: Don't Trust an Old Session After Authentication Settings Change
This was the part of Stage 4 where I spent the most time experimenting.
The theme was:
fail-closed behavior.
Fail-closed means:
when something is wrong or uncertain, default to the safer state.
A traffic signal is an easy analogy.
If the signal system breaks, you don't want:
"Something is wrong, so let's assume green."
You want:
"Something is wrong, so stop."
The First Problem I Found
I found that an existing authenticated session might still restore the administrator even after:
ADMIN_PASSWORD_HASH
had been changed to an invalid or different value.
So I created a RED test:
Successful login
↓
change ADMIN_PASSWORD_HASH
↓
GET /dashboard
↓
expect 302 redirect to /login
But then another problem appeared.
The RED Test Wasn't Following the Path I Thought It Was
In the test environment, the application context remained alive longer than I expected.
That meant:
g._login_user
still contained authentication information from the previous request.
I thought I was testing:
session
↓
load_user()
but the request was actually using:
previous request's g._login_user
↓
still authenticated
So I learned something important:
A test being RED does not automatically mean it is RED for the reason you intended.
I changed the test so that the cached authentication state from the previous request was removed.
I also used a spy to confirm that:
load_user("admin")
was actually called again.
Even then:
Expected: 302
Actual: 200
This time, I had confirmed the real RED condition.
One lesson that stood out during this work was:
It's not enough to ask whether GREEN is GREEN for the right reason. RED also needs to be RED for the right reason.
My First Fix Was a Roughly 60-Line Hash Parser
My first implementation tried to validate whether a Werkzeug password hash itself had a valid format.
I wrote a helper that parsed things such as:
- scrypt
- pbkdf2
- digest
- parameters
The tests passed.
But after reviewing the implementation, another problem became obvious:
it was far too complicated for what I was actually trying to achieve.
Now the application itself would need to understand and maintain Werkzeug's password-hash formats.
If Werkzeug added or changed supported formats in the future, my own parser might also need to change.
So I stopped and redesigned the approach.
Switching to a Fingerprint
The real question I wanted to answer was not:
"Is the current password hash in a valid format?"
It was:
ADMIN_PASSWORD_HASH at login
==
Current ADMIN_PASSWORD_HASH
So instead, I generate a SHA-256 fingerprint from ADMIN_PASSWORD_HASH when login succeeds and store that fingerprint in the session.
Conceptually:
Successful login
↓
record a "fingerprint" of the current ADMIN_PASSWORD_HASH
↓
next request
↓
generate a fingerprint from the current ADMIN_PASSWORD_HASH
↓
match → continue
mismatch → require login again
Only the fingerprint is stored in the session.
I do not store:
- the password hash itself
- the real password
- a plaintext password
The fingerprint logic was separated into a small helper.
In company terms:
instead of storing the key itself, I added someone who checks the key's fingerprint.
The final two tests verify:
ADMIN_PASSWORD_HASH changed
→ reject existing session
ADMIN_PASSWORD_HASH unchanged
→ restore session normally
Result:
2 passed
6. Can the Application Reject a Tampered CSRF Token?
The final area I checked was CSRF protection.
The existing pytest suite already verified:
No CSRF token
→ reject the POST request
But I had not yet tested:
a token that exists but has been modified or forged.
Using an airport analogy:
No passport
→ rejected
is not the only case worth checking.
I also wanted:
Forged passport
→ rejected
For these tests, I did not generate an entirely fake token from scratch.
Instead:
- retrieve a valid CSRF token using the same client
- modify only the first character
- send the POST request
I added three tests.
Login
Correct username/password
+
tampered CSRF token
↓
400
↓
authentication not created
Product Registration
Tampered CSRF token
↓
400
↓
Product unchanged
DailySales unchanged
Sales Registration
Tampered CSRF token
↓
400
↓
DailySales unchanged
Result:
3 passed
All three tests were GREEN immediately because Flask-WTF was already rejecting the tampered tokens correctly.
So this wasn't a case where I added a new security feature.
Instead:
I added evidence to the regression suite that an existing security checkpoint was actually working.
Final pytest Result
After all Stage 4 changes, I ran:
pytest -v
The result:
collected 87 items
...
======================= 87 passed, 2 warnings in 15.65s ========================
There were no failures or errors.
Stage 4 Commit List
bb8b503 test: cover empty database migrations
a1a5578 fix: reject invalid dashboard query parameters
b36ad7a test: cover Gemini failure fallbacks
757ef6f test: cover authenticated AI advice route
412fcb3 fix: invalidate sessions when admin credentials change
26ae4f8 test: cover invalid CSRF tokens
Verifying Everything Again with GitHub Actions
After confirming all 87 tests were GREEN locally, I used the following flow:
feature/pytest-stage4
↓
Pull Request
↓
GitHub Actions
↓
main
In the Pull Request, I confirmed:
All checks have passed
and verified that there were no conflicts before merging.
After the merge, I updated my local main branch:
git switch main
git pull
Then, as an additional check, I ran:
git merge-base --is-ancestor feature/pytest-stage4 main \
&& echo "OK: feature/pytest-stage4 is fully included in main" \
|| echo "NG: main still has missing commits"
The result:
OK: feature/pytest-stage4 is fully included in main
The SHA values for main and origin/main also matched.
There were no file differences between the feature branch and main.
Finally, after the merge, I ran:
pytest -v
again on main.
The final result remained:
87 passed, 2 warnings
In company terms, I think of this as:
Build the safety equipment in the test department
↓
Send it through headquarters review
↓
Approve it for official use
↓
Run all 87 safety checks again at headquarters
The Two Remaining Warnings
There are still two warnings during the migration tests:
DeprecationWarning:
'get_engine' is deprecated and will be removed in Flask-SQLAlchemy 3.2.
They originate from:
migrations/env.py
where get_engine() is still used.
The new migration test made these warnings more visible, but they were not introduced by the Stage 4 changes.
I am aware of these warnings, but I deliberately left them outside the scope of Stage 4.
Instead of fixing something just because I happened to notice it, I prefer to treat unrelated changes as separate tasks.
What I Learned from Stage 4
The test suite grew from:
71
↓
87
But once again, the number itself was not the most important part.
The fail-closed work in particular followed this path:
1. Write a RED test
2. Confirm RED
3. Investigate why it is RED
4. Discover a problem in the test fixture
5. Fix the RED test
6. Confirm the real RED condition
7. Implement a fix
8. Decide the implementation is too complicated
9. Throw away the first implementation
10. Redesign it using a simpler fingerprint approach
That experience made me think beyond:
"Does the code work?"
I also needed to ask:
"Do I really want to maintain this implementation in the future?"
And pytest has become easier for me to understand when I think of it not merely as a behavior check, but as:
a mechanism for checking dangerous places before an incident happens.
That feels very similar to hazard anticipation in logistics.
Instead of waiting for danger to appear, you constantly ask:
"What might happen next?"
Next: Stage 5 Will Be a Crash Test
I plan for Stage 5 to be the final stage of this pytest improvement series.
Up to this point, most of the work has been about:
adding tests and building safety mechanisms.
Stage 5 reverses the question:
"Can those safety mechanisms actually detect an accident?"
On a feature branch, I plan to temporarily introduce small intentional defects into the code and check whether pytest correctly turns RED.
Something like:
Intentionally introduce a small defect
↓
Run pytest
↓
Confirm the appropriate test turns RED
↓
Immediately restore the original code
This is similar to a manual form of mutation testing.
The intentionally broken code will not be committed or pushed.
I will always restore the original implementation before moving to the next check.
This is not something I will perform in the production environment.
At the end of Stage 1, the suite had only 9 tests.
At the end of Stage 4:
87 tests
In the final stage, the question will no longer be:
"Do I have 87 tests?"
It will be:
"Can those 87 tests actually detect the failures they are supposed to catch?"
pytest Improvement Series
Stage 1
https://qiita.com/tosane932/items/f3de1e190873a90de39fStage 2
https://qiita.com/tosane932/items/b91261e7103df5792f7dStage 3
https://qiita.com/tosane932/items/6d1ca5490979c8cf9d62Stage 4
https://qiita.com/tosane932/items/372270330e73583a227fStage 5
https://qiita.com/tosane932/items/85fd24c7baa6fe7c76a7
Top comments (0)