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 * `POST /items`: creates an item
* `GET /items`: returns the list of items * `GET /items`: returns the list of items
## Usage ## Running and testing
This project uses Leiningen. Assuming you already have Leiningen installed, 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 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 ## 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/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://cljdoc.org/d/metosin/reitit/0.10.0/doc/ring/swagger-support>
* <https://www.baeldung.com/clojure-ring> * <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/reitit-swagger-ui "0.10.0"]
[metosin/muuntaja "0.6.11"] [metosin/muuntaja "0.6.11"]
[com.github.seancorfield/next.jdbc "1.3.1086"] [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 :main ^:skip-aot yohoho.app
:target-path "target/%s" :target-path "target/%s"
:profiles {:uberjar {:aot :all :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 {}})))))