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.