Add unit and integration tests

I won't be lying, Claude Code was extremely helpful to write those
tests, although it had a lot of issues with properly using in-memory
SQLite (db and integration tests) and with parsing responses
(integration test).
This commit is contained in:
2026-02-01 06:00:01 +01:00
parent b78b8cca41
commit 449a4a7d75
7 changed files with 507 additions and 3 deletions

View File

@@ -9,11 +9,14 @@ endpoints :
* `POST /items`: creates an item
* `GET /items`: returns the list of items
## Usage
## Running and testing
This project uses Leiningen. Assuming you already have Leiningen installed,
running the API should be as easy as cloning the repo and issuing `lein run`. By
default, the server listens on port 3000.
default, the server listens on port 3000. To explore the API using Swagger UI,
you can head to <http://localhost:3000/doc>.
To run the unit and integration tests: `lein test`
## Structure
@@ -48,3 +51,4 @@ The following links proved more than useful when working on this assignment:
* <https://cljdoc.org/d/com.github.seancorfield/next.jdbc/1.3.1086/doc/getting-started>
* <https://cljdoc.org/d/metosin/reitit/0.10.0/doc/ring/swagger-support>
* <https://www.baeldung.com/clojure-ring>
* <https://practical.li/clojure/testing/unit-testing/>

View File

@@ -12,7 +12,8 @@
[metosin/reitit-swagger-ui "0.10.0"]
[metosin/muuntaja "0.6.11"]
[com.github.seancorfield/next.jdbc "1.3.1086"]
[org.xerial/sqlite-jdbc "3.51.1.0"]]
[org.xerial/sqlite-jdbc "3.51.1.0"]
[org.clojure/data.json "2.5.1"]]
:main ^:skip-aot yohoho.app
:target-path "target/%s"
:profiles {:uberjar {:aot :all

106
test/yohoho/db_test.clj Normal file
View File

@@ -0,0 +1,106 @@
(ns yohoho.db-test
(:require
[clojure.test :refer :all]
[next.jdbc :as jdbc]
[yohoho.db :as db]))
;; In-memory database setup ---------------------------------------
(def schema-sql
"CREATE TABLE items (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL
);")
(defn with-memory-db [f]
(let [conn (jdbc/get-connection
{:dbtype "sqlite"
:dbname ":memory:"})]
;; create schema on the SAME connection
(jdbc/execute! conn [schema-sql])
;; force db.clj to reuse this connection
(with-redefs [db/db conn]
(f))
(.close conn)))
(use-fixtures :each with-memory-db)
;; Tests ----------------------------------------------------------
(deftest test-init
(testing "Database initialization creates items table"
;; Drop the table first to test initialization
(try
(jdbc/execute! db/db ["DROP TABLE IF EXISTS items"])
(catch Exception _))
(db/init!)
;; Verify table exists by trying to query it
(is (vector? (db/get-all-items)))))
(deftest test-store-item
(testing "Storing item returns an ID"
(let [id (db/store-item! "Test Item" "test@example.com")]
(is (number? id))
(is (> id 0))))
(testing "Stored item can be retrieved"
(let [id (db/store-item! "Test Item" "test@example.com")
item (db/get-item id)]
(is (= "Test Item" (:name item)))
(is (= "test@example.com" (:email item)))
(is (= id (:id item)))))
(testing "Multiple items get unique IDs"
(let [id1 (db/store-item! "Item 1" "item1@example.com")
id2 (db/store-item! "Item 2" "item2@example.com")]
(is (not= id1 id2)))))
(deftest test-get-item
(testing "Getting non-existent item returns nil"
(is (nil? (db/get-item 999))))
(testing "Getting existing item returns map with correct keys"
(let [id (db/store-item! "Test" "test@example.com")
item (db/get-item id)]
(is (map? item))
(is (contains? item :id))
(is (contains? item :name))
(is (contains? item :email))
;; Keys should be unqualified
(is (not (qualified-keyword? :id)))
(is (not (qualified-keyword? :name)))
(is (not (qualified-keyword? :email)))))
(testing "Retrieved item has correct values"
(let [id (db/store-item! "My Item" "myitem@example.com")
item (db/get-item id)]
(is (= id (:id item)))
(is (= "My Item" (:name item)))
(is (= "myitem@example.com" (:email item))))))
(deftest test-get-all-items
(testing "Empty database returns empty vector"
(is (= [] (db/get-all-items))))
(testing "Database with items returns all items"
(db/store-item! "Item 1" "item1@example.com")
(db/store-item! "Item 2" "item2@example.com")
(db/store-item! "Item 3" "item3@example.com")
(let [items (db/get-all-items)]
(is (= 3 (count items)))
(is (every? map? items))
(is (every? #(contains? % :id) items))
(is (every? #(contains? % :name) items))
(is (every? #(contains? % :email) items))))
(testing "Items are returned with unqualified keys"
(db/store-item! "Test" "test@example.com")
(let [items (db/get-all-items)
item (first items)]
(is (not (qualified-keyword? :id)))
(is (not (qualified-keyword? :name)))
(is (not (qualified-keyword? :email))))))

View File

@@ -0,0 +1,65 @@
(ns yohoho.handlers-test
(:require [clojure.test :refer :all]
[yohoho.handlers :as handler]
[yohoho.db :as db]))
(deftest test-ahoy
(testing "Health check endpoint returns 200"
(let [response (handler/ahoy {})]
(is (= 200 (:status response)))
(is (string? (:body response)))
(is (re-find #"Ahoy" (:body response))))))
(deftest test-get-items
(testing "Getting all items returns proper structure"
(with-redefs [db/get-all-items (fn [] [{:id 1 :name "Test" :email "test@example.org"}
{:id 2 :name "Test2" :email "test2@example.org"}])]
(let [response (handler/get-items {})]
(is (= 200 (:status response)))
(is (map? (:body response)))
(is (vector? (get-in response [:body :items])))
(is (= 2 (count (get-in response [:body :items])))))))
(testing "Getting items with empty database"
(with-redefs [db/get-all-items (fn [] [])]
(let [response (handler/get-items {})]
(is (= 200 (:status response)))
(is (= [] (get-in response [:body :items])))))))
(deftest test-get-item
(testing "Getting existing item returns 200"
(with-redefs [db/get-item (fn [id]
(when (= id 1)
{:id 1 :name "Test" :email "test@example.org"}))]
(let [request {:path-params {:id 1}}
response (handler/get-item request)]
(is (= 200 (:status response)))
(is (= {:id 1 :name "Test" :email "test@example.org"} (:body response))))))
(testing "Getting non-existent item returns 404"
(with-redefs [db/get-item (fn [_] nil)]
(let [request {:path-params {:id 999}}
response (handler/get-item request)]
(is (= 404 (:status response)))
(is (= {:error "Not Found"} (:body response)))))))
(deftest test-create-item
(testing "Creating item with valid data returns 201"
(with-redefs [db/store-item! (fn [_name _email] 42)
db/get-item (fn [id]
(when (= id 42)
{:id 42 :name "New Item" :email "new@example.org"}))]
(let [request {:body-params {:name "New Item" :email "new@example.org"}}
response (handler/create-item request)]
(is (= 201 (:status response)))
(is (= "/item/42" (get-in response [:headers "Location"])))
(is (= {:id 42 :name "New Item" :email "new@example.org"} (:body response))))))
(testing "Created item has all expected fields"
(with-redefs [db/store-item! (fn [_name _email] 1)
db/get-item (fn [id] {:id id :name "Test" :email "test@example.org"})]
(let [request {:body-params {:name "Test" :email "test@example.org"}}
response (handler/create-item request)]
(is (contains? (:body response) :id))
(is (contains? (:body response) :name))
(is (contains? (:body response) :email))))))

View File

@@ -0,0 +1,35 @@
(ns yohoho.helpers-test
(:require [clojure.test :refer :all]
[yohoho.helpers :as helpers]))
(deftest test-post-payload-content-type-json-middleware
(let [mock-handler (fn [_request] {:status 200 :body "Success"})
wrapped-handler (helpers/post-payload-content-type-json-middleware mock-handler)]
(testing "GET requests pass through without content-type check"
(let [request {:request-method :get}
response (wrapped-handler request)]
(is (= 200 (:status response)))
(is (= "Success" (:body response)))))
(testing "POST with application/json passes through"
(let [request {:request-method :post
:headers {"content-type" "application/json"}}
response (wrapped-handler request)]
(is (= 200 (:status response)))
(is (= "Success" (:body response)))))
(testing "POST without content-type returns 415"
(let [request {:request-method :post
:headers {}}
response (wrapped-handler request)]
(is (= 415 (:status response)))
(is (= "Unsupported Media Type" (get-in response [:body :error])))
(is (= "Content-Type must be application/json" (get-in response [:body :message])))))
(testing "POST with wrong content-type returns 415"
(let [request {:request-method :post
:headers {"content-type" "application/xml"}}
response (wrapped-handler request)]
(is (= 415 (:status response)))
(is (= "Unsupported Media Type" (get-in response [:body :error])))))))

View File

@@ -0,0 +1,196 @@
(ns yohoho.integration-test
(:require [clojure.test :refer :all]
[yohoho.app :as app]
[yohoho.db :as db]
[next.jdbc :as jdbc]
[clojure.data.json :as json]))
;; In-memory database setup ---------------------------------------
(def schema-sql
"CREATE TABLE items (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL
);")
(defn with-memory-db [f]
(let [conn (jdbc/get-connection
{:dbtype "sqlite"
:dbname ":memory:"})]
;; create schema on the SAME connection
(jdbc/execute! conn [schema-sql])
;; force db.clj to reuse this connection
(with-redefs [db/db conn]
(f))
(.close conn)))
(use-fixtures :each with-memory-db)
;; Helpers --------------------------------------------------------
(defn parse-json-body [response]
(when-let [body (:body response)]
(cond
(string? body)
(json/read-str body :key-fn keyword)
(instance? java.io.InputStream body)
(json/read (java.io.InputStreamReader. body) :key-fn keyword)
:else
body)))
(defn make-request
"Helper to make a request to the app"
[method uri & {:keys [body headers]}]
(let [request {:request-method method
:uri uri
:headers (merge {"content-type" "application/json"} headers)}
request (if body
(assoc request :body-params body)
request)]
(app/app request)))
;; Tests ----------------------------------------------------------
(deftest test-health-check
(testing "Health check endpoint returns 200"
(let [response (make-request :get "/ahoy")]
(is (= 200 (:status response)))
(is (string? (:body response))))))
(deftest test-create-and-retrieve-item
(testing "Creating an item and retrieving it"
(let [item-data {:name "Integration Test Item" :email "integration@test.com"}
create-response (make-request :post "/items" :body item-data)]
;; Verify creation response
(is (= 201 (:status create-response)))
(let [created-item (parse-json-body create-response)]
(is (number? (:id created-item)))
(is (= "Integration Test Item" (:name created-item)))
(is (= "integration@test.com" (:email created-item)))
;; Verify Location header
(is (= (str "/item/" (:id created-item))
(get-in create-response [:headers "Location"])))
;; Retrieve the item by ID
(let [get-response (make-request :get (str "/item/" (:id created-item)))
retrieved-item (parse-json-body get-response)]
(is (= 200 (:status get-response)))
(is (= created-item retrieved-item)))))))
(deftest test-get-all-items
(testing "Getting all items returns empty list initially"
(let [response (make-request :get "/items")
body (parse-json-body response)]
(is (= 200 (:status response)))
(is (= [] (:items body)))))
(testing "Getting all items after creating some"
;; Create multiple items
(make-request :post "/items" :body {:name "Item 1" :email "item1@test.com"})
(make-request :post "/items" :body {:name "Item 2" :email "item2@test.com"})
(make-request :post "/items" :body {:name "Item 3" :email "item3@test.com"})
(let [response (make-request :get "/items")
body (parse-json-body response)]
(is (= 200 (:status response)))
(is (= 3 (count (:items body))))
(is (every? #(contains? % :id) (:items body)))
(is (every? #(contains? % :name) (:items body)))
(is (every? #(contains? % :email) (:items body))))))
(deftest test-get-nonexistent-item
(testing "Getting non-existent item returns 404"
(let [response (make-request :get "/item/999")
body (parse-json-body response)]
(is (= 404 (:status response)))
(is (= "Not Found" (:error body))))))
(deftest test-create-item-validation
(testing "Creating item without email fails validation"
(let [response (make-request :post "/items" :body {:name "Test"})]
(is (= 400 (:status response)))))
(testing "Creating item without name fails validation"
(let [response (make-request :post "/items" :body {:email "test@example.com"})]
(is (= 400 (:status response)))))
(testing "Creating item with invalid email fails validation"
(let [response (make-request :post "/items" :body {:name "Test" :email "not-an-email"})]
(is (= 400 (:status response)))))
(testing "Creating item with empty payload fails validation"
(let [response (make-request :post "/items" :body {})]
(is (= 400 (:status response))))))
(deftest test-content-type-validation
(testing "POST without content-type returns 415"
(let [response (make-request :post "/items" :body {:name "Test" :email "test@test.com"}
:headers {"content-type" nil})]
(is (= 415 (:status response)))))
(testing "POST with wrong content-type returns 415"
(let [response (make-request :post "/items" :body {:name "Test" :email "test@test.com"}
:headers {"content-type" "application/xml"})]
(is (= 415 (:status response))))))
(deftest test-invalid-routes
(testing "Non-existent route returns 404"
(let [response (make-request :get "/nonexistent")]
(is (= 404 (:status response))))))
(deftest test-openapi-endpoint
(testing "OpenAPI documentation is available"
(let [response (make-request :get "/openapi.json")]
(is (= 200 (:status response)))
(let [openapi-doc (parse-json-body response)]
(is (= "Yo-ho-ho API" (get-in openapi-doc [:info :title])))
(is (contains? openapi-doc :paths))))))
(deftest test-item-id-coercion
(testing "Item ID path parameter is coerced to integer"
;; Create an item first
(let [create-response (make-request :post "/items"
:body {:name "Test" :email "test@test.com"})
created-item (parse-json-body create-response)
item-id (:id created-item)]
;; Request with string ID should work (coerced to int)
(let [response (make-request :get (str "/item/" item-id))]
(is (= 200 (:status response))))))
(testing "Invalid ID format returns 400"
(let [response (make-request :get "/item/not-a-number")]
(is (= 400 (:status response))))))
(deftest test-multiple-items-workflow
(testing "Complete workflow: create multiple items, retrieve all, retrieve individual"
(let [items-to-create [{:name "Alice" :email "alice@example.com"}
{:name "Bob" :email "bob@example.com"}
{:name "Charlie" :email "charlie@example.com"}]
created-ids (atom [])]
;; Create items
(doseq [item items-to-create]
(let [response (make-request :post "/items" :body item)
created (parse-json-body response)]
(is (= 201 (:status response)))
(swap! created-ids conj (:id created))))
;; Verify all items in list
(let [list-response (make-request :get "/items")
all-items (:items (parse-json-body list-response))]
(is (= 3 (count all-items)))
(is (= #{"Alice" "Bob" "Charlie"} (set (map :name all-items)))))
;; Verify each item individually
(doseq [id @created-ids]
(let [response (make-request :get (str "/item/" id))]
(is (= 200 (:status response)))
(is (= id (:id (parse-json-body response)))))))))

View File

@@ -0,0 +1,97 @@
(ns yohoho.schemas-test
(:require [clojure.test :refer :all]
[yohoho.schemas :as s]
[malli.core :as m]))
(deftest test-email-validation
(testing "Valid email addresses"
(is (m/validate s/Email "test@example.com"))
(is (m/validate s/Email "user.name@example.com"))
(is (m/validate s/Email "user+tag@example.co.uk"))
(is (m/validate s/Email "user_123@test-domain.com"))
(is (m/validate s/Email "a@b.c")))
(testing "Invalid email addresses"
(is (not (m/validate s/Email "notanemail")))
(is (not (m/validate s/Email "@example.com")))
(is (not (m/validate s/Email "user@")))
(is (not (m/validate s/Email "user @example.com")))
(is (not (m/validate s/Email "user@example")))
(is (not (m/validate s/Email "")))))
(deftest test-item-input-schema
(testing "Valid item input"
(is (m/validate s/ItemInput {:name "Test Item" :email "test@example.com"}))
(is (m/validate s/ItemInput {:name "A" :email "a@b.c"})))
(testing "Invalid item input - missing fields"
(is (not (m/validate s/ItemInput {:name "Test"})))
(is (not (m/validate s/ItemInput {:email "test@example.com"})))
(is (not (m/validate s/ItemInput {}))))
(testing "Invalid item input - wrong types"
(is (not (m/validate s/ItemInput {:name 123 :email "test@example.com"})))
(is (not (m/validate s/ItemInput {:name "Test" :email "not-an-email"}))))
(testing "Extra fields are allowed (open map)"
(is (m/validate s/ItemInput {:name "Test" :email "test@example.com" :extra "field"}))))
(deftest test-item-schema
(testing "Valid item"
(is (m/validate s/Item {:id 1 :name "Test" :email "test@example.com"}))
(is (m/validate s/Item {:id 999 :name "Long Name Here" :email "complex.email+tag@example.co.uk"})))
(testing "Invalid item - missing fields"
(is (not (m/validate s/Item {:id 1 :name "Test"})))
(is (not (m/validate s/Item {:id 1 :email "test@example.com"})))
(is (not (m/validate s/Item {:name "Test" :email "test@example.com"}))))
(testing "Invalid item - wrong types"
(is (not (m/validate s/Item {:id "not-a-number" :name "Test" :email "test@example.com"})))
(is (not (m/validate s/Item {:id 1.5 :name "Test" :email "test@example.com"})))
(is (not (m/validate s/Item {:id 1 :name "Test" :email "invalid-email"})))))
(deftest test-items-response-schema
(testing "Valid items response"
(is (m/validate s/ItemsResponse {:items []}))
(is (m/validate s/ItemsResponse {:items [{:id 1 :name "Test" :email "test@example.com"}]}))
(is (m/validate s/ItemsResponse {:items [{:id 1 :name "Test1" :email "test1@example.com"}
{:id 2 :name "Test2" :email "test2@example.com"}]})))
(testing "Invalid items response - not a vector"
(is (not (m/validate s/ItemsResponse {:items {:id 1 :name "Test" :email "test@example.com"}})))
(is (not (m/validate s/ItemsResponse {:items "not-a-vector"}))))
(testing "Invalid items response - missing items key"
(is (not (m/validate s/ItemsResponse {})))
(is (not (m/validate s/ItemsResponse {:data []}))))
(testing "Invalid items response - invalid item in vector"
(is (not (m/validate s/ItemsResponse {:items [{:id 1 :name "Test"}]})))))
(deftest test-error-response-schema
(testing "Valid error response with message"
(is (m/validate s/ErrorResponse {:error "Not Found" :message "Item does not exist"})))
(testing "Valid error response without message"
(is (m/validate s/ErrorResponse {:error "Not Found"})))
(testing "Invalid error response - missing error"
(is (not (m/validate s/ErrorResponse {:message "Something went wrong"})))
(is (not (m/validate s/ErrorResponse {}))))
(testing "Invalid error response - wrong types"
(is (not (m/validate s/ErrorResponse {:error 404 :message "Not Found"})))
(is (not (m/validate s/ErrorResponse {:error "Not Found" :message 123})))))
(deftest test-error-400-response-schema
(testing "Valid 400 error response"
(is (m/validate s/Error400Response {:value {}
:type "reitit.coercion/request-coercion"
:coercion "malli"
:in ["request" "body-params"]
:humanized {:email ["should be a valid email"]}})))
(testing "Invalid 400 error - missing required fields"
(is (not (m/validate s/Error400Response {:value {}})))
(is (not (m/validate s/Error400Response {:type "type" :coercion "malli" :in [] :humanized {}})))))