Skip to main content

Testing

On this page

Built-in unit testing with no boilerplate. Define tests as functions, run them from the CLI.

Quick start#

(ns my-app.math-test
  (:require phel.test :refer [deftest is]))

(deftest addition-works
  (is (= 4 (+ 2 2))))

(deftest string-concat
  (is (= "hello world" (str "hello" " " "world")))
  (is (not (= "" (str "a" "b")))))

Run:

./vendor/bin/phel test

Output:

....
2 tests, 3 assertions, 0 failures.
PHP Coming from PHP?

No class boilerplate. Tests are plain functions:

// PHPUnit
class MathTest extends TestCase {
    public function testAddition() {
        $this->assertEquals(4, 2 + 2);
    }
}

// Phel
(deftest addition-works
  (is (= 4 (+ 2 2))))

Assertions#

The is macro defines assertions. Optional second argument is a description string shown on failure.

(is (= 4 (+ 2 2)))
(is (= 4 (+ 2 2)) "2 + 2 should be 4")

Equality and predicates#

(is (= expected actual))           ; equality
(is (true? value))                 ; predicate
(is (not (= "x" (str "a" "b"))))  ; negation
(is (nil? (get {} :missing)))      ; any predicate works

For collection equality, failures render a unified diff (added in 0.37) so missing/extra entries are obvious:

FAIL (= a b)
--- expected
+++ actual
 [:a 1
- :b 2
+ :b 99
  :c 3]

Exceptions#

;; assert throws
(is (thrown? Exception
      (throw (php/new Exception "test"))))

;; assert throws with specific message
(is (thrown-with-msg? Exception "test"
      (throw (php/new Exception "test"))))

Output#

;; assert what gets printed to stdout
(is (output? "hello" (print "hello")))
PHP Coming from PHP?

Exception testing more concise than PHPUnit:

// PHPUnit
$this->expectException(Exception::class);
throw new Exception("test");

// or
$this->expectException(Exception::class);
$this->expectExceptionMessage("test");
throw new Exception("test");

// Phel (inline exception assertions)
(is (thrown? Exception (throw (php/new Exception "test"))))
(is (thrown-with-msg? Exception "test" (throw (php/new Exception "test"))))

The output? assertion is similar to PHPUnit's output buffering:

// PHPUnit
$this->expectOutputString("hello");
echo "hello";

// Phel
(is (output? "hello" (print "hello")))

Defining tests#

deftest defines a test. Each test can contain any number of is assertions. A test passes when all assertions pass.

(ns my-app.cart-test
  (:require phel.test :refer [deftest is])
  (:require my-app.cart :refer [add-item total]))

(deftest empty-cart-has-zero-total
  (is (= 0 (total []))))

(deftest add-item-increases-total
  (let [cart (add-item [] {:price 10 :qty 2})]
    (is (= 20 (total cart)))
    (is (= 1 (count cart)))))

(deftest rejects-negative-price
  (is (thrown? Exception (add-item [] {:price -5 :qty 1}))))

Running tests#

Run via ./vendor/bin/phel test. Picks up tests recursively from withTestDirs, defaults to tests/.

Pass filenames to run specific files:

./vendor/bin/phel test tests/main.phel tests/utils.phel

Filter by name with --filter:

./vendor/bin/phel test tests/utils.phel --filter my-test-function

Stop on first failure with --fail-fast:

./vendor/bin/phel test --fail-fast

Print discovered tests without running them (--list), re-run only failures from the previous run (--last-failed), or print the N slowest tests after the summary (--slowest=N):

./vendor/bin/phel test --list
./vendor/bin/phel test --last-failed
./vendor/bin/phel test --slowest=10

--last-failed persists failures to .phel/last-failed.txt.

--testdox for TestDox format. --quiet for errors only, --silent to silence fully.

Full options: ./vendor/bin/phel test --help.

Reporters#

Pick format with --reporter=<name>. Repeatable for multiple formats.

ReporterDescription
defaultHuman-readable summary (default)
testdoxSentence-style names
dotOne character per test
tapTest Anything Protocol
junit-xmlJUnit XML (use --output=path for a file)
./vendor/bin/phel test --reporter=dot
./vendor/bin/phel test --reporter=junit-xml --output=build/tests.xml
./vendor/bin/phel test --reporter=tap --reporter=junit-xml --output=build/tests.xml

phel.test/report is a multimethod dispatching on event :type. Register custom reporters from Phel.

Selectors#

Filter by tag, namespace glob, or regex:

./vendor/bin/phel test --include=integration
./vendor/bin/phel test --exclude=slow
./vendor/bin/phel test --ns='my-app.http.*'
./vendor/bin/phel test --filter 'user.*login'

Tag tests with metadata:

(deftest ^:integration full-signup-flow
  ...)

(deftest ^{:tags [:integration :slow]} heavy-job
  ...)

Skipped tests emit :skipped event.

Repeat and random order#

Re-run each test N times, randomize discovery order, and seed for reproducible runs:

./vendor/bin/phel test --repeat=10            # stress a flaky test
./vendor/bin/phel test --random-order         # random order, random seed
./vendor/bin/phel test --random-order --seed=42  # deterministic

--seed=<int> alone fixes the seed for the default deterministic order.

PHP Coming from PHP?

Test command similar to PHPUnit:

# PHPUnit
./vendor/bin/phpunit tests/
./vendor/bin/phpunit tests/MainTest.php
./vendor/bin/phpunit --filter testMyFunction

# Phel
./vendor/bin/phel test
./vendor/bin/phel test tests/main.phel
./vendor/bin/phel test --filter my-test-function

Both support filtering, verbose output, specific files.

Run tests from Phel code with run-tests. Takes options map (can be empty) and one or more namespaces.

(run-tests {} 'my.ns.a 'my.ns.b)

Interactive testing with test-ns#

Run tests for a single namespace from the REPL:

(ns my-app.tests
  (:require phel.test :refer [deftest is test-ns]))

; Run all tests in a namespace
(test-ns 'my-app.tests)

Useful for REPL-driven feedback without running the full suite.

Test statistics#

Manage stats programmatically:

; Reset test counters to zero
(reset-stats)

; Get current test statistics (pass/fail/error counts)
(get-stats)

; Save and restore stats around a test run
(def saved (get-stats))
(test-ns 'my-app.tests)
(restore-stats saved)

Useful in REPL to isolate or reset state between runs.

Mocking#

phel.mock module replaces functions with test doubles.

Creating mocks#

(ns my-app.tests
  (:require phel.test :refer [deftest is])
  (:require phel.mock :refer [mock mock-fn mock-returning mock-throwing
                               calls call-count called? called-with?
                               called-once? never-called? reset-mock!
                               with-mocks]))

;; Fixed return value
(def my-mock (mock :ok))
(my-mock "any" "args")  ; => :ok

;; Custom behavior
(def double-mock (mock-fn #(* % 2)))
(double-mock 5)  ; => 10

;; Consecutive return values
(def seq-mock (mock-returning [1 2 3]))
(seq-mock)  ; => 1
(seq-mock)  ; => 2
(seq-mock)  ; => 3

;; Mock that throws
(def err-mock (mock-throwing (php/new RuntimeException "fail")))

Inspecting calls#

(def m (mock :result))
(m "a" "b")
(m "c")

(calls m)          ; => [["a" "b"] ["c"]]
(call-count m)     ; => 2
(called? m)        ; => true
(called-with? m "a" "b")  ; => true
(called-once? m)   ; => false
(never-called? m)  ; => false

Replacing functions in tests#

with-mocks temporarily replaces functions via dynamic binding. Auto-resets after the block:

(defn fetch-user [id]
  ;; ... makes HTTP call ...
  )

(deftest test-with-mock
  (with-mocks [fetch-user (mock {:id 1 :name "Alice"})]
    (is (= {:id 1 :name "Alice"} (fetch-user 42)))
    (is (called-once? fetch-user))))
PHP Coming from PHP?

Simpler than Mockery or PHPUnit mocks:

// PHPUnit
$mock = $this->createMock(UserService::class);
$mock->method('find')->willReturn(['id' => 1]);

// Phel
(with-mocks [find-user (mock {:id 1})]
  (find-user 42))

No class structure. Mock any function directly.

Property-based testing#

Instead of writing specific examples, describe properties that must hold for any input. Phel generates random inputs and shrinks failures to the smallest reproducing case.

(ns my-app.tests
  (:require phel.test :refer [deftest is defspec])
  (:require phel.test.gen :as gen))

;; Property: reversing twice gives back the original (holds for any vector of ints)
(defspec reverse-roundtrip
  [xs (gen/vector (gen/int))]
  (is (= xs (reverse (reverse xs)))))

;; Property: sorting is idempotent (sort of a sorted list is still sorted)
(defspec sort-idempotent
  [xs (gen/vector (gen/int))]
  (let [sorted (sort xs)]
    (is (= sorted (sort sorted)))))

On failure, Phel shrinks the input to the smallest case that still fails, then reports :shrunk-args, :original-args, :shrink-steps, and a :seed to reproduce the run.

Available generators: gen/int, gen/string, gen/boolean, gen/keyword, gen/vector, gen/map, gen/one-of, gen/frequency, gen/such-that, and more in phel.test.gen.

Opt out of shrinking with ^:no-shrink metadata or :shrink? false.