init commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
FROM babashka/babashka:latest
|
||||
|
||||
COPY integration_test.bb /integration_test.bb
|
||||
|
||||
ENTRYPOINT ["bb", "/integration_test.bb"]
|
||||
@@ -0,0 +1,80 @@
|
||||
# Integration Tests
|
||||
|
||||
Dockerized integration tests for the vibe server.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Run the full test suite (builds the app, starts it, runs tests):
|
||||
|
||||
```bash
|
||||
cd integration-tests
|
||||
docker-compose up --abort-on-container-exit
|
||||
```
|
||||
|
||||
## Manual Run (app already running)
|
||||
|
||||
If your vibe app is running locally on port 3000:
|
||||
|
||||
```bash
|
||||
cd integration-tests
|
||||
docker run --rm -v "$(pwd)/integration_test.bb:/integration_test.bb:ro" babashka/babashka:latest bb /integration_test.bb http://host.docker.internal:3000
|
||||
```
|
||||
|
||||
Or run directly on a host with babashka installed:
|
||||
|
||||
```bash
|
||||
cd integration-tests
|
||||
bb integration_test.bb http://localhost:3000
|
||||
```
|
||||
|
||||
Or from the repo root, via the `bb.edn` task:
|
||||
|
||||
```bash
|
||||
bb integration-test
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|--------------|
|
||||
| `[base-url]` (positional) | `http://localhost:3000` | Server to test against |
|
||||
| `--watch` | off | Re-run the suite on a timer instead of exiting after one run |
|
||||
| `--interval <ms>` | `5000` | Delay between runs in watch mode |
|
||||
|
||||
Example — watch a local server every 2 seconds:
|
||||
|
||||
```bash
|
||||
bb integration-test http://localhost:3000 --watch --interval 2000
|
||||
```
|
||||
|
||||
## What's Tested
|
||||
|
||||
| # | Test | Checks |
|
||||
|---|------|--------|
|
||||
| 1 | `GET /` returns 200 | HTTP status code |
|
||||
| 2 | `Content-Type` header | `text/plain` |
|
||||
| 3 | Response body | Contains expected Clojure/Ring content |
|
||||
| 4 | Not HTML | Confirms plain text response |
|
||||
| 5 | `HEAD /` works | HTTP 200 |
|
||||
|
||||
## Line Coverage
|
||||
|
||||
Line coverage is measured with [Cloverage](https://github.com/cloverage/cloverage) for the
|
||||
unit tests, the integration tests, and both combined. From the repo root:
|
||||
|
||||
| Task | What it does | Output |
|
||||
|------|--------------|--------|
|
||||
| `bb unit-coverage` | Runs `test/` under Cloverage | `target/coverage/unit/` |
|
||||
| `bb integration-coverage` | Runs the integration suite against a Cloverage-instrumented `vibe-app` container | `target/coverage/integration/` |
|
||||
| `bb coverage-report` | Merges the two `lcov.info` files, prints a per-file table | `target/coverage/merged/` |
|
||||
| `bb coverage` | All three, in order (the full suite with a joint report) | all of the above |
|
||||
|
||||
Each directory contains an `index.html` (open it in a browser) and an `lcov.info`. In the
|
||||
merged report a line counts as covered if either suite hit it; the HTML shows per-line hit
|
||||
counts per suite (`u:` unit, `i:` integration).
|
||||
|
||||
How the integration numbers are collected: `docker-compose.coverage.yml` starts the app via
|
||||
`vibe.integration.coverage`, which lets Cloverage instrument `src/` and then serves real
|
||||
traffic until it receives SIGTERM. `bb integration-coverage` stops the container
|
||||
(`docker compose stop`) so the report gets written, then copies it out with `docker compose cp`.
|
||||
Plain `bb integration-test` / `bb test` are unchanged and run the uninstrumented app.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Overlay for `bb integration-coverage`: runs vibe-app under Cloverage instead of plainly.
|
||||
# Reports land in /app/target/coverage/integration inside the container; bb.edn copies
|
||||
# them out after the app is stopped (SIGTERM is what triggers the report).
|
||||
services:
|
||||
vibe-app:
|
||||
build:
|
||||
args:
|
||||
DEPS_ALIASES: run:cov
|
||||
command:
|
||||
- clojure
|
||||
- -M:cov
|
||||
- -m
|
||||
- vibe.integration.coverage
|
||||
- --runner
|
||||
- integration
|
||||
- --src-ns-path
|
||||
- src
|
||||
- --extra-test-ns
|
||||
- vibe.integration.coverage
|
||||
- --lcov
|
||||
- --html
|
||||
- --output
|
||||
- /app/target/coverage/integration
|
||||
stop_grace_period: 2m
|
||||
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
vibe-app:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "${VIBE_PORT:-3000}:${VIBE_PORT:-3000}"
|
||||
security_opt:
|
||||
- apparmor:unconfined
|
||||
environment:
|
||||
- PORT=${VIBE_PORT:-3000}
|
||||
# No container healthcheck: healthchecks run via `docker exec`, which hits the
|
||||
# same LXC/AppArmor runc bug (opencontainers/runc#5007) as image builds and
|
||||
# always fails here. integration_test.bb polls the server until it's ready.
|
||||
|
||||
integration-test:
|
||||
image: babashka/babashka:latest
|
||||
security_opt:
|
||||
- apparmor:unconfined
|
||||
volumes:
|
||||
- ./integration_test.bb:/integration_test.bb:ro
|
||||
depends_on:
|
||||
vibe-app:
|
||||
condition: service_started
|
||||
entrypoint: ["bb", "/integration_test.bb", "http://vibe-app:${VIBE_PORT:-3000}"]
|
||||
Executable
+179
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
(ns integration-test
|
||||
(:require [babashka.http-client :as http]
|
||||
[clojure.edn :as edn]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(def default-config
|
||||
{:base-url "http://localhost:3000"
|
||||
:max-retries 30
|
||||
:retry-delay-ms 1000
|
||||
:watch? false
|
||||
:watch-interval-ms 5000})
|
||||
|
||||
(defn get* [base-url]
|
||||
(http/get base-url {:throw false}))
|
||||
|
||||
(defn head* [base-url]
|
||||
(http/head base-url {:throw false}))
|
||||
|
||||
(defn wait-for-server [{:keys [base-url max-retries retry-delay-ms]}]
|
||||
(println (str "=== Waiting for server at " base-url " ==="))
|
||||
(loop [i 1]
|
||||
(let [ready? (try (= 200 (:status (get* base-url))) (catch Exception _ false))]
|
||||
(cond
|
||||
ready? (do (println "Server is ready!") true)
|
||||
(= i max-retries) (do (println (format "ERROR: Server did not become ready after %d attempts" max-retries))
|
||||
false)
|
||||
:else (do (Thread/sleep retry-delay-ms) (recur (inc i)))))))
|
||||
|
||||
(defn run-suite
|
||||
"Runs the integration suite once against config's :base-url.
|
||||
Returns {:pass n :fail n :errors [...]}"
|
||||
[{:keys [base-url] :as config}]
|
||||
(if-not (wait-for-server config)
|
||||
{:pass 0 :fail 1 :errors ["server did not become ready"]}
|
||||
(let [pass (atom 0)
|
||||
fail (atom 0)
|
||||
errors (atom [])
|
||||
assert-eq (fn [label expected actual]
|
||||
(if (= expected actual)
|
||||
(do (println " ✓" label) (swap! pass inc))
|
||||
(do (println " ✗" label)
|
||||
(println " expected:" expected)
|
||||
(println " actual: " actual)
|
||||
(swap! fail inc)
|
||||
(swap! errors conj (format "%s: expected '%s' but got '%s'" label expected actual)))))
|
||||
assert-contains (fn [label needle haystack]
|
||||
(if (str/includes? haystack needle)
|
||||
(do (println " ✓" label) (swap! pass inc))
|
||||
(do (println " ✗" label)
|
||||
(println " expected to contain:" needle)
|
||||
(println " actual: " haystack)
|
||||
(swap! fail inc)
|
||||
(swap! errors conj (format "%s: did not find '%s'" label needle)))))
|
||||
assert-not-empty (fn [label value]
|
||||
(if-not (str/blank? value)
|
||||
(do (println " ✓" label) (swap! pass inc))
|
||||
(do (println " ✗" label)
|
||||
(println " expected non-empty value")
|
||||
(swap! fail inc)
|
||||
(swap! errors conj (format "%s: value was empty" label)))))]
|
||||
(println)
|
||||
(println "=== Running Integration Tests ===")
|
||||
(println)
|
||||
|
||||
(println "Test 1: GET / returns 200")
|
||||
(assert-eq "HTTP status" 200 (:status (get* base-url)))
|
||||
(println)
|
||||
|
||||
(println "Test 2: Content-Type is text/plain")
|
||||
(assert-contains "Content-Type" "text/plain" (get (:headers (get* base-url)) "content-type" ""))
|
||||
(println)
|
||||
|
||||
(println "Test 3: Root response points at the API docs")
|
||||
(let [body (:body (get* base-url))]
|
||||
(assert-contains "Body mentions /api-docs" "/api-docs" body)
|
||||
(assert-not-empty "Body is non-empty" body)
|
||||
(println)
|
||||
|
||||
(println "Test 4: Response is plain text, not HTML")
|
||||
(if (and (not (str/includes? body "<!DOCTYPE"))
|
||||
(not (str/includes? body "<html")))
|
||||
(do (println " ✓ Response is not HTML") (swap! pass inc))
|
||||
(do (println " ✗ Response appears to be HTML")
|
||||
(swap! fail inc)
|
||||
(swap! errors conj "Response was HTML, expected plain text"))))
|
||||
(println)
|
||||
|
||||
(println "Test 5: HEAD / returns 200")
|
||||
(assert-eq "HEAD status" 200 (:status (head* base-url)))
|
||||
(println)
|
||||
|
||||
(println "Test 6: GET /api/ping returns 200 with body 'pong'")
|
||||
(let [resp (get* (str base-url "/api/ping"))]
|
||||
(assert-eq "HTTP status" 200 (:status resp))
|
||||
(assert-eq "Body" "pong" (:body resp)))
|
||||
(println)
|
||||
|
||||
(println "Test 7: GET /swagger.json returns a valid-looking spec")
|
||||
(let [resp (get* (str base-url "/swagger.json"))
|
||||
body (:body resp)]
|
||||
(assert-eq "HTTP status" 200 (:status resp))
|
||||
(assert-contains "Content-Type" "application/json" (get (:headers resp) "content-type" ""))
|
||||
(assert-contains "Spec references /api/ping" "/api/ping" body))
|
||||
(println)
|
||||
|
||||
(println "Test 8: GET /api-docs serves the Swagger UI")
|
||||
(let [resp (get* (str base-url "/api-docs"))]
|
||||
(assert-eq "HTTP status" 200 (:status resp))
|
||||
(assert-contains "Content-Type" "text/html" (get (:headers resp) "content-type" "")))
|
||||
(println)
|
||||
|
||||
(println "Test 9: GET /api/add?x=2&y=3 returns {:result 5} as EDN")
|
||||
(let [resp (get* (str base-url "/api/add?x=2&y=3"))]
|
||||
(assert-eq "HTTP status" 200 (:status resp))
|
||||
(assert-contains "Content-Type" "application/edn" (get (:headers resp) "content-type" ""))
|
||||
(assert-eq "Body" {:result 5} (edn/read-string (:body resp))))
|
||||
(println)
|
||||
|
||||
(println "Test 10: GET /api/add with a missing or non-numeric param returns 400")
|
||||
(assert-eq "Missing y" 400 (:status (get* (str base-url "/api/add?x=2"))))
|
||||
(assert-eq "Non-numeric x" 400 (:status (get* (str base-url "/api/add?x=abc&y=3"))))
|
||||
(println)
|
||||
|
||||
(println "================================")
|
||||
(println (format " Results: %d passed, %d failed" @pass @fail))
|
||||
(println "================================")
|
||||
|
||||
(if (pos? @fail)
|
||||
(do (println) (println "Failures:") (doseq [e @errors] (println (str " - " e))))
|
||||
(do (println) (println "All integration tests passed! ✓")))
|
||||
|
||||
{:pass @pass :fail @fail :errors @errors})))
|
||||
|
||||
(defn parse-args
|
||||
"Parses CLI args into a config override map.
|
||||
Supported: [base-url] [--watch] [--interval <ms>]"
|
||||
[args]
|
||||
(loop [args args
|
||||
overrides {}]
|
||||
(if (empty? args)
|
||||
overrides
|
||||
(let [[arg & more] args]
|
||||
(cond
|
||||
(= arg "--watch") (recur more (assoc overrides :watch? true))
|
||||
(= arg "--interval") (recur (rest more) (assoc overrides :watch-interval-ms (parse-long (first more))))
|
||||
:else (recur more (assoc overrides :base-url arg)))))))
|
||||
|
||||
(defn -main [& args]
|
||||
(let [config (merge default-config (parse-args args))
|
||||
{:keys [watch?]} config]
|
||||
(if watch?
|
||||
(do
|
||||
(println "Watch mode: watching for file changes. Press Ctrl+C to stop.")
|
||||
(println "Watching: integration_test.bb")
|
||||
(println)
|
||||
;; Run once, then wait for inotify events
|
||||
(run-suite config)
|
||||
(println)
|
||||
(println "Waiting for file changes...")
|
||||
(println)
|
||||
;; Use inotifywait to watch the file for modifications
|
||||
(let [proc (Runtime/exec (into-array String ["inotifywait" "-e" "modify" "-q" "integration_test.bb"]))]
|
||||
(try
|
||||
(.waitFor proc)
|
||||
(finally
|
||||
(.destroy proc)))
|
||||
(recur args)))
|
||||
(let [{:keys [fail]} (run-suite config)]
|
||||
(System/exit (if (pos? fail) 1 0))))))
|
||||
|
||||
|
||||
(comment
|
||||
(run-suite {:base-url "http://localhost:3000"}))
|
||||
|
||||
;; bb runs this file as a script, not via -m — nothing calls -main otherwise.
|
||||
(apply -main *command-line-args*)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
(ns vibe.integration.coverage
|
||||
"Entry point that runs the vibe server under Cloverage instrumentation so the
|
||||
black-box integration tests produce a line-coverage report.
|
||||
|
||||
Cloverage normally instruments the source and then runs a test suite in-process.
|
||||
Here the 'suite' is the outside world: the runner starts the real server and blocks
|
||||
until the JVM gets SIGTERM (`docker compose stop`), then Cloverage writes its reports.
|
||||
|
||||
Deliberately does not require any vibe.* source namespace at load time: those must
|
||||
be loaded by Cloverage (instrumented), not by us."
|
||||
(:require [cloverage.coverage :as cov]))
|
||||
|
||||
(defmethod cov/runner-fn :integration [_]
|
||||
(fn [_nses]
|
||||
(let [stop (promise)
|
||||
main (Thread/currentThread)]
|
||||
((requiring-resolve 'vibe.core/start-server))
|
||||
;; The report is written by the main thread after the runner returns, but the JVM
|
||||
;; only stays up while shutdown hooks run, so the hook must wait for the main thread.
|
||||
(.addShutdownHook (Runtime/getRuntime)
|
||||
(Thread. (fn []
|
||||
(deliver stop true)
|
||||
(.join main))))
|
||||
@stop
|
||||
{:errors 0})))
|
||||
|
||||
(defn -main [& args]
|
||||
;; Cloverage's own System/exit would deadlock inside the shutdown hook above.
|
||||
(binding [cov/*exit-after-test* false]
|
||||
(apply cov/-main args))
|
||||
(shutdown-agents))
|
||||
Reference in New Issue
Block a user