diff --git a/Cargo.lock b/Cargo.lock
index 645af33..621664d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1073,6 +1073,7 @@ dependencies = [
  "railwind",
  "serde",
  "sqlx",
+ "test-log",
  "thiserror",
  "tokio",
  "tower",
@@ -2174,6 +2175,27 @@ dependencies = [
  "windows-sys 0.48.0",
 ]
 
+[[package]]
+name = "test-log"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6159ab4116165c99fc88cce31f99fa2c9dbe08d3691cb38da02fc3b45f357d2b"
+dependencies = [
+ "test-log-macros",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "test-log-macros"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ba277e77219e9eea169e8508942db1bf5d8a41ff2db9b20aab5a5aadc9fa25d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.39",
+]
+
 [[package]]
 name = "thiserror"
 version = "1.0.52"
diff --git a/Cargo.toml b/Cargo.toml
index f91ffdd..70b5f31 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -40,3 +40,4 @@ walkdir = "2"
 
 [dev-dependencies]
 http-body-util = "0.1.0"
+test-log = { version = "0.2.14", features = ["trace"], default-features = false }
diff --git a/src/tests/index.rs b/src/tests/index.rs
index dd0cd7a..1b5382d 100644
--- a/src/tests/index.rs
+++ b/src/tests/index.rs
@@ -1,20 +1,16 @@
-use axum::{
-    body::Body,
-    http::{Request, StatusCode},
-};
+use axum::http::StatusCode;
 use sqlx::{Pool, Postgres};
-use tower::ServiceExt; // for `call`, `oneshot`, and `ready`
 
-#[sqlx::test]
-async fn index(pool: Pool<Postgres>) -> anyhow::Result<()> {
-    let app = crate::server::app(pool).await.unwrap();
+use super::util::TestApp;
 
-    let response = app
-        .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
-        .await
-        .unwrap();
+#[test_log::test(sqlx::test)]
+async fn index(pool: Pool<Postgres>) -> anyhow::Result<()> {
+    let mut app = TestApp::new(pool).await;
 
-    assert_eq!(response.status(), StatusCode::SEE_OTHER);
+    app.req()
+        .expect_status(StatusCode::SEE_OTHER)
+        .get("/")
+        .await;
 
     Ok(())
 }
diff --git a/src/tests/mod.rs b/src/tests/mod.rs
index a8d63d9..c9176e5 100644
--- a/src/tests/mod.rs
+++ b/src/tests/mod.rs
@@ -2,3 +2,4 @@
 //! for information on why our tests are inside the `src` folder.
 mod index;
 mod users;
+mod util;
diff --git a/src/tests/users.rs b/src/tests/users.rs
index e8a3147..9361125 100644
--- a/src/tests/users.rs
+++ b/src/tests/users.rs
@@ -1,24 +1,13 @@
-use axum::{
-    body::Body,
-    http::{Request, StatusCode},
-    response::IntoResponse,
-    Router,
-};
-use http_body_util::BodyExt;
+use axum::http::StatusCode;
 use sqlx::{Pool, Postgres};
-use tower::Service;
-use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
-use visdom::Vis;
 
-use crate::schemas::users::{CreateUser, Credentials}; // for `call`, `oneshot`, and `ready`
+use crate::{
+    schemas::users::{CreateUser, Credentials},
+    tests::util::TestApp,
+};
 
-#[sqlx::test]
+#[test_log::test(sqlx::test)]
 async fn can_login(pool: Pool<Postgres>) -> anyhow::Result<()> {
-    tracing_subscriber::registry()
-        .with(EnvFilter::from_default_env())
-        .with(tracing_subscriber::fmt::layer())
-        .init();
-
     let mut tx = pool.begin().await?;
     crate::db::users::create_user_if_not_exists(
         &mut tx,
@@ -30,46 +19,29 @@ async fn can_login(pool: Pool<Postgres>) -> anyhow::Result<()> {
     .await?;
     tx.commit().await?;
 
-    let mut app = crate::server::app(pool).await.unwrap();
-
-    let response = <Router as tower::ServiceExt<Request<Body>>>::ready(&mut app)
-        .await?
-        .call(Request::builder().uri("/login").body(Body::empty())?)
-        .await
-        .unwrap();
+    let mut app = TestApp::new(pool).await;
 
-    assert_eq!(response.status(), StatusCode::OK);
+    let login_page = app.req().get("/login").await.dom().await;
 
-    let body = String::from_utf8(response.into_body().collect().await?.to_bytes().to_vec())?;
-    let dom = Vis::load(body).expect("Failed to parse HTML");
-    let form = dom.find("form");
+    let form = login_page.find("form");
     let username = form.find("input[name='username'][type='text'][required]");
     assert!(!username.is_empty());
     let password = form.find("input[name='password'][type='password'][required]");
     assert!(!password.is_empty());
 
-    let creds = axum::Form(Credentials {
+    let creds = Credentials {
         username: "test".to_string(),
         password: "test".to_string(),
-    })
-    .into_response()
-    .into_body();
+    };
 
-    let response = <Router as tower::ServiceExt<Request<Body>>>::ready(&mut app)
-        .await?
-        .call(
-            Request::builder()
-                .method("POST")
-                .uri("/login")
-                .header("Content-Type", "application/x-www-form-urlencoded")
-                .body(creds)?,
-        )
-        .await
-        .unwrap();
+    let login_response = app
+        .req()
+        .expect_status(StatusCode::SEE_OTHER)
+        .post("/login", &creds)
+        .await;
 
-    assert_eq!(response.status(), StatusCode::SEE_OTHER);
-    let cookie = response.headers().get("Set-Cookie").unwrap();
-    dbg!(cookie);
+    let cookie = login_response.headers().get("Set-Cookie").unwrap();
+    assert!(!cookie.is_empty());
 
     Ok(())
 }
diff --git a/src/tests/util/mod.rs b/src/tests/util/mod.rs
new file mode 100644
index 0000000..9dcf986
--- /dev/null
+++ b/src/tests/util/mod.rs
@@ -0,0 +1,23 @@
+use axum::Router;
+use sqlx::{Pool, Postgres};
+
+use crate::server::app;
+
+use self::request_builder::RequestBuilder;
+
+pub mod request_builder;
+
+pub struct TestApp {
+    router: Router,
+}
+
+impl TestApp {
+    pub async fn new(pool: Pool<Postgres>) -> Self {
+        TestApp {
+            router: app(pool).await.unwrap(),
+        }
+    }
+    pub fn req(&mut self) -> RequestBuilder {
+        RequestBuilder::new(&mut self.router)
+    }
+}
diff --git a/src/tests/util/request_builder.rs b/src/tests/util/request_builder.rs
new file mode 100644
index 0000000..24a9716
--- /dev/null
+++ b/src/tests/util/request_builder.rs
@@ -0,0 +1,94 @@
+use askama_axum::IntoResponse;
+use axum::{
+    body::Body,
+    http::{self, HeaderMap, Request, Response, StatusCode},
+    Form, Router,
+};
+use http_body_util::BodyExt;
+use mime_guess::mime;
+use serde::Serialize;
+use tower::{Service, ServiceExt};
+use visdom::Vis;
+
+pub struct RequestBuilder<'app> {
+    router: &'app mut axum::Router,
+    /// This is the HTTP status that we expect the backend to return.
+    /// If it returns a different status, we'll panic.
+    expected_status: StatusCode,
+}
+
+impl<'app> RequestBuilder<'app> {
+    pub fn new(router: &'app mut Router) -> Self {
+        RequestBuilder {
+            router: router,
+            expected_status: StatusCode::OK,
+        }
+    }
+
+    pub fn expect_status(mut self, expected: StatusCode) -> Self {
+        self.expected_status = expected;
+        self
+    }
+
+    pub async fn post<Input>(mut self, url: &str, input: &Input) -> TestResponse
+    where
+        Input: Serialize,
+    {
+        let request = Request::builder()
+            .method(http::Method::POST)
+            .uri(url)
+            .header(
+                http::header::CONTENT_TYPE,
+                mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(),
+            )
+            .body(Form(input).into_response().into_body())
+            .unwrap();
+
+        let response = ServiceExt::<Request<Body>>::ready(&mut self.router)
+            .await
+            .unwrap()
+            .call(request)
+            .await
+            .unwrap();
+
+        assert_eq!(response.status(), self.expected_status);
+
+        TestResponse { response }
+    }
+
+    pub async fn get(mut self, url: &str) -> TestResponse {
+        let request = Request::builder().uri(url).body(Body::empty()).unwrap();
+
+        let response = ServiceExt::<Request<Body>>::ready(&mut self.router)
+            .await
+            .unwrap()
+            .call(request)
+            .await
+            .unwrap();
+
+        assert_eq!(response.status(), self.expected_status);
+        TestResponse { response: response }
+    }
+}
+
+pub struct TestResponse {
+    response: Response<Body>,
+}
+
+impl TestResponse {
+    pub async fn dom(self) -> visdom::types::Elements<'static> {
+        let body = self
+            .response
+            .into_body()
+            .collect()
+            .await
+            .unwrap()
+            .to_bytes()
+            .to_vec();
+        Vis::load(String::from_utf8(body).unwrap()).unwrap()
+    }
+
+    pub fn headers(&self) -> &HeaderMap {
+        self.response.headers()
+    }
+}