The port is feature-complete. The last lesson does what a professional does
before calling a rewrite finished: hardens the test suite until it argues
for itself, settles an architectural debt honestly, and then weighs the
result against the thing it replaced. Checkpoint:
lesson-12.
The split the tests demanded
Rust integration tests live in tests/, compile as a separate crate, and
can only import from a library. For eleven lessons this app was a binary,
and the moment tests/web.rs needed to build the router, that stopped
working. The fix is the standard Rust move: src/lib.rs exporting the
modules, main.rs shrinking to a dozen lines that call
db::pool and web::router.
Notice what finally forced the split — not a style guide, not “libraries are better,” but a concrete consumer that couldn’t exist without it. Hold that thought; it’s the theme of this lesson.
Property tests, and the bug they found in minutes
Lesson 7 tested the cart at hand-picked points: two angelfish and a goldfish is $38.50. proptest checks claims across generated input space:
proptest! {
#[test]
fn totals_never_panic_or_go_negative(
price in 0i64..=i64::MAX,
quantity in 1u32..=u32::MAX,
) {
let mut cart = Cart::default();
cart.add(item("EST-1"), "x".into(), Cents(price));
cart.set_quantity(&item("EST-1"), quantity);
prop_assert!(cart.total() >= Cents(0));
}
}
Run that against lesson 7’s code and it fails in milliseconds: attempt to
multiply with overflow. Cents::times did price * quantity unchecked;
a hostile price near i64::MAX times a capped-but-large quantity blows
past 64 bits and panics. No realistic catalog price triggers it — which is
exactly why five lessons of example-based tests never found it, and why a
generator did on its first try. The fix is saturating arithmetic in both
money operations: a total pinned at i64::MAX is absurd, but absurd beats
aborted. The failing property stays in the suite as the regression guard.
Two more properties pin the cart’s contract: totals always equal the sum of line subtotals, and n adds of one item always merge into one line of quantity n. Example tests show the code works; properties say what the code promises.
Integration tests without a socket
tests/web.rs drives the real router — session layer, extractors, error
pages, everything — with tower’s oneshot, against a migrated and seeded
in-memory database. No port, no server process, no sleeps:
let response = app.clone().oneshot(
Request::post("/cart/items")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from("item_id=EST-1"))
.unwrap(),
).await.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
The cart test plucks the session cookie out of the redirect and carries it into the next request, exactly as a browser would — the whole add-then-view flow, verified in memory in milliseconds. Six of these cover the storefront, the 404, the embedded images, the session round-trip, and the auth gate.
The trait that earned its non-existence
Lesson 4 made a promise: plain functions for data access, and a repository trait when testing pressure demands one. This is the lesson where that bill came due, so let’s settle it: the pressure never arrived.
Every db test in this suite runs against the real schema, real migrations,
real seed data, in an in-memory database that costs milliseconds. There was
never a moment where a test wanted a MockCatalogRepo, because the real
thing was already faster to use and impossible to drift. The Java original
needs its @MockBean machinery because standing up a real database was
expensive; make the real thing cheap and the abstraction built to avoid it
has no job.
So the port ships with zero repository traits, and that’s not a shortcut — it’s the no-premature-abstraction rule followed to its honest conclusion. The one abstraction this lesson did add, the lib/bin split, got in because a real consumer demanded it. That’s the whole discipline, demonstrated twice in one lesson from opposite directions.
The scorecard
Numbers from a release build on my dev machine — run your own with
cargo build --release:
| Measure | jpetstore-6 (Java) | jpetstore-rs |
|---|---|---|
| Deployment artifact | WAR file + an app server to put it in | one 7.4 MB binary, pet pictures included |
| Direct dependencies | Spring, MyBatis, Stripes + their trees via Maven | 8 crates, every one introduced by name in a lesson |
| Resident memory, serving | a JVM heap, typically hundreds of MB | 7.2 MB |
| SQL checked | at runtime, when the mapper loads | at cargo build |
| Templates checked | at runtime, per JSP compile | at cargo build |
| Passwords | plaintext in the seed | argon2id |
| Card numbers | a column in orders |
nowhere to put one |
| Tests | none shipped | 35: unit, property, request-level |
Two honest asterisks. The Java stack buys real things for its weight — hot-swap deploys, a vast ecosystem, three decades of operational knowledge. And a JVM’s memory pays for a garbage collector doing real work. The claim is not that the old world was foolish; it’s that the gap between “an app server hosting your WAR” and “a binary you copy and run” is now this wide, and you’ve walked every step of it yourself.
Build it
| File | Action | What goes in it |
|---|---|---|
Cargo.toml |
generated | cargo add --dev proptest and cargo add --dev tower --features util — dev-deps, so the scorecard’s 8 stands |
src/lib.rs |
write | Four pub mod lines and the comment explaining why |
src/main.rs |
modify | Thin shell: use jpetstore_rs::{db, web};, body unchanged |
src/domain/cart.rs |
write | The three properties — write the hostile one first, watch it fail, then fix |
src/domain/catalog.rs |
modify | saturating_mul / saturating_add in the two money operations |
tests/web.rs |
write | The six oneshot tests; the session round-trip is the template for any flow you add |
docs/port-plan.md (yours, if you kept one) |
modify | Record the trait decision’s outcome — plans that log their results stay trustworthy |
Done when: cargo test shows 35 passing across both suites; the
hostile-inputs property fails if you revert the saturating fix; and
cargo build --release hands you a single-digit-megabyte pet store.
Answer key: lesson-11...lesson-12.
Challenge: break the port
The final challenge is adversarial. Pick any invariant this course claimed — carts never go negative, paid prices are frozen, strangers’ orders 404, the card number touches nothing — and write the test that tries hardest to falsify it. If the test passes, you’ve strengthened the suite. If it fails, you’ve found lesson 13, and I’d genuinely like to hear about it.
Where the port goes next
The free course ends here, with the app the Java original was — running as
one binary you built yourself, cargo run away from a demo. What it isn’t
yet is deployed: no container, no TLS, no real host. That story — and
the bigger one, where the server-rendered store becomes an API with a
client-side UI across the CSR/SSR/SSG spectrum — is what the paid
follow-ups are for. Watch the courses page — and thanks for
porting a pet store with me.