7 Commits

Author SHA1 Message Date
2dc7eb7417 Add GitHub Actions CI workflow for main, develop, and release tags
Some checks failed
CI / macos-latest - Debug (push) Has been cancelled
CI / ubuntu-latest - Debug (push) Has been cancelled
CI / macos-latest - Release (push) Has been cancelled
CI / ubuntu-latest - Release (push) Has been cancelled
2026-10-23 15:28:07 -07:00
a089557df7 Merge pull request 'Adds top-level CMake build (T-04): static JTest lib, example, ctest smoke' (#1) from t01-cmake-build into develop
Reviewed-on: #1
2026-09-28 06:55:17 +00:00
senior-eng
fbf5621eae Adds top-level CMake build (T-04): static JTest lib, example, ctest smoke
The project was Makefile-only and non-portable. Adds a top-level
CMakeLists.txt so the framework builds with a standard build system:

* add_library(JTest STATIC ...) over the 13 src/JTest/*.cpp translation
  units. No find_package/FetchContent is needed: a full #include scan of
  include/, src/, examples/ shows the framework depends only on the C++
  standard library plus its own <JTest/...> headers.
* target_include_directories(JTest PUBLIC include) so consumers that link
  JTest (the example, tests) inherit the include path transitively.
* target_compile_features(JTest PUBLIC cxx_std_17) propagates the C++17
  requirement (sources use std::optional).
* Portable (no -arch) build: the Makefile's non-portable -arch flag is not
  carried over (that is T-20's scope).
* enable_testing() + add_test() running tests/smoke.cpp, a genuine self-check
  that exercises the public API and exits non-zero on any failed assertion, so
  ctest is a real pass/fail gate. The demo example remains a build/run target
  but is intentionally excluded from the CTest gate (it contains deliberate
  failing/skipping cases to showcase reporting and always exits 0).

Verified end-to-end (CMake 4.4.3, AppleClang 21, out-of-source build):
configure OK, builds libJTest.a + example + test_smoke, ctest passes
(smoke: Passed, 100%), example demo runs.
2026-09-27 10:05:45 -07:00
2b6c3e59a9 Merge commit 'c4898f2' into develop 2026-09-25 02:12:42 -07:00
f0bfec2634 Adds Project.md: team reference doc on the JTest C++ Jasmine-style testing framework 2026-09-24 12:25:15 -07:00
c4898f20fe Adds Project.md: team reference doc on the JTest C++ Jasmine-style testing framework 2026-09-24 05:22:00 -07:00
75a091f36c Adds .worktrees to gitignore. 2026-09-23 19:56:52 -07:00
5 changed files with 492 additions and 0 deletions

38
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: CI
on:
push:
branches:
- main
- develop
tags:
- 'v*'
pull_request:
branches:
- main
- develop
jobs:
build-and-test:
name: ${{ matrix.os }} - ${{ matrix.build_type }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
build_type: [Debug, Release]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Configure CMake
run: >
cmake -B build -S .
-DCMAKE_BUILD_TYPE=${{ matrix.build_type }}
- name: Build
run: cmake --build build --config ${{ matrix.build_type }}
- name: Test with CTest
run: ctest --test-dir build --output-on-failure -C ${{ matrix.build_type }}

1
.gitignore vendored
View File

@@ -34,3 +34,4 @@ tmp
*.exe
*.out
*.app
.worktrees

52
CMakeLists.txt Normal file
View File

@@ -0,0 +1,52 @@
cmake_minimum_required(VERSION 3.14)
project(JTest
VERSION 0.1.0
DESCRIPTION "A minimal, header-light Jasmine-style C++ test framework (no external dependencies)."
LANGUAGES CXX)
# ---------------------------------------------------------------------------
# Static library
# The framework has NO external dependency: it only includes C++ standard
# library headers plus its own <JTest/...> headers (verified by a full
# #include scan of include/, src/, examples/). No find_package/FetchContent is
# needed.
#
# JTEST_SOURCES is globbed with CONFIGURE_DEPENDS so newly added translation
# units are picked up automatically; the build dir does the heavy lifting.
# JTest is PUBLIC on its include dir so that anything linking it inherits the
# include path transitively -- the demo executable and tests rely on this.
# ---------------------------------------------------------------------------
file(GLOB JTEST_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/src/JTest/*.cpp")
add_library(JTest STATIC ${JTEST_SOURCES})
target_include_directories(JTest PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include")
# Require C++17 (the sources use std::optional etc.) for JTest's sources and
# propagate the requirement to any target that links JTest.
target_compile_features(JTest PUBLIC cxx_std_17)
# ---------------------------------------------------------------------------
# Examples
# Build the demonstration as an executable, but do NOT register it with CTest:
# examples/example.cpp deliberately contains failing/skipping cases to show
# JTest's reporting output and always returns 0, so a CTest gate on it would
# be noise. Keep it a normal target so it still builds and runs.
# ---------------------------------------------------------------------------
add_executable(example examples/example.cpp)
target_link_libraries(example PRIVATE JTest)
# ---------------------------------------------------------------------------
# Testing
# enable_testing() + add_test() wire a genuine self-check that actually
# passes. tests/smoke.cpp exercises the public API (describe/it/expect/
# execute) and returns non-zero on any failed assertion, so `ctest` is a real
# pass/fail gate: a broken or misconfigured build goes red, not green.
# ---------------------------------------------------------------------------
enable_testing()
add_executable(test_smoke tests/smoke.cpp)
target_link_libraries(test_smoke PRIVATE JTest)
add_test(NAME smoke COMMAND test_smoke)

348
Project.md Normal file
View File

@@ -0,0 +1,348 @@
# JTest — A C++ Test Framework That Feels Like Jasmine
This document is the single point of reference for anyone new to the **JTest**
project. It explains what we are building, why, how it maps to the JavaScript
testing framework **Jasmine**, and how the pieces fit together today. If you
only read one file, read this one first.
---
## 1. What is JTest, and who is it for?
**One-sentence summary:** JTest is a C++ unit-testing framework whose API is
deliberately modelled on JavaScript's Jasmine, so that anyone who already knows
Jasmine can write C++ tests without relearning the vocabulary.
**The job-to-be-done:** a developer testing C++ code wants the same comfort,
readability, and rhythm they get from Jasmine — `describe`/`it`/`expect`/`toBe` —
but in a native, header-and-source C++ form, compiled locally with `clang++`.
**Who the users are:**
- **Test authors** — C++ developers who write test files using `describe`,
`it`, `expect(...).toBe(...)` etc.
- **Framework maintainers** — the small team (you) that extends JTest itself.
**What it is NOT (yet):** it is not a drop-in for GoogleTest, not a CI runner,
and not complete. It is an evolving design. See [§8 Current State & Roadmap](#8-current-state--roadmap).
---
## 2. Why the name "Jasmine"? (the vocabulary mapping)
Jasmine is one of the most widely recognised behaviour-driven testing (BDD)
frameworks in JavaScript. JTest reuses its three pillars one-for-one, plus the
Jasmine "pending" and "failure" idioms. The mapping below is the core mental
model of the whole project.
| Concept in JTest | Jasmine equivalent | What it does |
|--------------------|--------------------|----------------------------------------------------------|
| `describe(label, makeTests)` | `describe` | Names a group of related tests; can nest. |
| `xdescribe(...)` | `xdescribe` | Same as `describe`, but the whole group is **disabled**. |
| `it(label, fn)` | `it` / `spec` | Declares a single test case that runs `fn`. |
| `xit(label, fn)` | `xit` | Declares a test that is **disabled** (skipped). |
| `expect(x)` | `expect` | Returns an `Expectable<T>` you assert on. |
| `.toBe(matcher)` | `toBe` | Assert equality / truth via a custom matcher function. |
| `.toBeTrue/False/Null/Equal(...)` | `toBe` family | Type-specific boolean/null/equality matchers. |
| `.toThrow(...)` | `toThrow` | Assert a callable throws (optionally a matching exception).|
| `.nevermore()` | `.not` | **Inverts** the next assertion (negation). See §4.4. |
| `expect(x).nevermore().toEqual(y)` | `expect(x).not.toBe(y)` | Negative assertion. |
| `beforeAll/afterAll/beforeEach/afterEach` | same names | Lifecycle hooks, supplied via `DescribeOptions`. |
| `fail(reason)` | `fail` | Force a test to fail with a message. |
| `pending(reason)` | `pending`/`xit` | Mark a test as pending (skipped) with a reason. |
> **Naming note for maintainers:** JTest can't use Jasmine's `.not()` because
> `not` is awkward in C++. The current chosen name is **`nevermore()`**. A shortlist
> of alternatives was tracked in `include/JTest/Expectable.h` — revisit before
> anything depends on it.
---
## 3. Project layout
```
JTest/
├── Project.md <-- you are here: the team reference doc
├── README.md <-- high-level, plus a TODO list (see §8)
├── LICENSE <-- MIT
├── Makefile <-- builds the framework + the example test binary
├── include/JTest/ <-- public headers (the API surface)
├── src/JTest/ <-- framework implementation (.cpp)
├── examples/ <-- example test sources that exercise the API
└── build/ <-- generated objects/binaries (gitignored target dir)
```
### 3.1 Headers (`include/JTest/`) — the public API
| Header | Responsibility |
|--------|----------------|
| `JTest.h` | Umbrella header. Declares the top-level functions: `execute`, `describe`, `xdescribe`, `it`, `xit`, `fail`, `pending`, and the `expect<T>(T)` template. **Start here.** |
| `Expectable.h` | The `Expectable<T>` matcher class — all the `toBe/toBeEqual/toBeTrue/.../toThrow` matchers plus negation. Header-only, templated. |
| `TestBundle.h` | A node in the test tree: holds `tests_` (leaf tests), `children_` (nested bundles), the four lifecycle hooks, and `disabled_`. |
| `Test.h` | A single leaf test: `label_`, `test_method_` (a `test_fn`), `disabled_`. |
| `TestResults.h` | Accumulated counters: `total / passed / failed / skipped`, with `operator+`, `+=`, and `operator<<` for reporting. |
| `TestOptions.h` | Placeholder for per-test options — currently an empty class. |
| `DescribeOptions.h` | Per-group options: `beforeAll/afterAll/beforeEach/afterEach` setters (fluent, return `*this`). |
| `ConfigureFunction.h` | `configure_fn = std::function<void()>` and a `combine(...)` helper that chains two optional hooks. |
| `TestFunction.h` | `test_fn = std::function<void()>` — the signature every `it(...)` body is wrapped in. |
| `TestStatus.h` | `enum TestStatus { Unknown, Passed, Failed, Skipped }`. |
| `TestException.h` | Base exception (`std::runtime_error`) carrying label/path/function/file/line; the foundation of test failures. |
| `TestFailedException.h` | `TestException` subclass = a reported test **failure** (`reason()`). |
| `TestPendingException.h` | `TestException` subclass = a **skipped/pending** test. |
| `FailedExpectation.h` | `TestFailedException` subclass thrown by a matcher when an assertion fails. |
### 3.2 Sources (`src/JTest/`)
`.cpp` implementations for: `ConfigureFunction`, `DescribeOptions`,
`FailedExpectation`, `JTest` (the runtime: `describe/it/execute`), `Test`,
`TestBundle`, `TestException`, `TestFailedException`, `TestOptions`,
`TestPendingException`, `TestResults`, `TestStatus`.
### 3.3 Examples (`examples/`)
- `example.cpp` — the **example test driver**. Contains several `describe` blocks
and a `main()` that runs them, collects a `TestResults`, and prints it. This is
the file you read to learn the API by example.
- `ClassToTest.h` / `ClassToTest.cpp` — currently **empty stubs** (a `MyNS`
namespace placeholder). Intended to be the first "class under test".
---
## 4. How the API works (concepts)
### 4.1 Describing and nesting
A test file builds a tree of `describe` blocks. Each `describe` takes a label and a
**factory function** (`make_testbundle_fn`) that returns the inner
`TestBundle`. Nesting is done by returning `TestBundle`s (or other `describe`
results) from those factories.
```cpp
describe("ClassToTest", [](){
return TestBundle(
{
it("should do the thing", [](){
// body
}),
it("should do the other thing", [](){
pending("we haven't made the other thing yet");
}),
},
DescribeOptions()
.beforeEach([](){ /* setup */ })
.afterEach([](){ /* teardown */ })
.beforeAll([](){ /* once before all */ })
.afterAll([](){ /* once after all */ }));
});
```
`xdescribe(...)` / `xit(...)` are the "disabled" variants.
### 4.2 Expect and matchers
`expect(actual)` returns an `Expectable<T>`. You chain a matcher onto it.
```cpp
expect(MyAddFunction(2, 2)).toEqual(4); // true → pass; false → throw
expect(x).toBeTrue(); // boolean-ish
expect(ptr).toBeNull(); // pointer
expect(fn).toThrow(std::runtime_error("...")); // callable throws
```
Matchers implemented today:
- `toBe(matcher)` — matcher is `std::optional<std::string>(const T&)`; returns a
value on failure → throws `FailedExpectation`.
- `toBeFalse()`, `toBeTrue()`, `toBeNull()`, `toEqual(value)`.
- `toThrow()` / `toThrow(exception)` / `toThrow(string)` / `toThrow(matcher)` —
several overloads for "assert this callable throws".
### 4.3 Negation (the `not` problem)
`Expectable::nevermore()` flips the internal `is_inverted_` flag so the next
matcher asserts the negative — JTest's stand-in for Jasmine's `.not()`.
```cpp
expect(2 + 2).nevermore().toEqual(5); // passes: they are not equal
```
### 4.4 Running tests
The top-level entry point is `execute(...)`. There are two overloads:
- `execute(TestBundle bundle, path="")` — runs a group; recursively handles
`beforeAll → children → tests (each wrapped in beforeEach/afterEach) → afterAll`.
- `execute(Test test, bundle_label)` — runs one test; increments the right
counter and prints a line.
A typical `main()` (see `examples/example.cpp`) accumulates results and prints
them:
```cpp
TestResults results;
results += run_group_a(args);
// ...
std::cout << results << std::endl;
```
### 4.5 Failure model (exceptions carry the signal)
JTest uses **exceptions to flow test outcomes**, then catches them at the
`execute(Test...)` level to turn them into counters + output:
- A matcher fails → throws `FailedExpectation` (a `TestFailedException`).
- `fail(reason)` → throws `TestFailedException`.
- `pending(reason)` → throws `TestPendingException`.
- Any uncaught `std::exception` / `...` → counted as a failure ("Unhandled
exception running test").
Result line formats (seen in real output — see §7):
- `🚧 Pending Test: <path>::<label>`
- `❌ Failed Test: <path>::<label> reason: <reason>`
- `🔥 Unhandled exception running test: <path>::<label> what: <what>`
- Final summary: `Total: N, Passed: N, Failed: N, Skipped: N`
### 4.6 Lifecycle hooks and inheritance
`beforeAll/afterAll/beforeEach/afterEach` are stored on a `TestBundle` as
`maybe_configure_fn` (an `optional<configure_fn>`). `combine(first, second)`
chains two hooks so parent hooks run relative to child hooks. `describe` merges
`DescribeOptions` hooks into the returned bundle via `combine`.
---
## 5. Building and running
Toolchain: **clang++, C++17, macOS / `clang++`** is assumed. The `Makefile`
targets `clang++` with `-std=c++17` and `-arch arm64`.
```bash
make # build framework objects + examples/example (all: target)
make run # build then execute the example test binary
make clean # wipe + recreate the build/ directories
```
- `make` builds `build/examples/example` by linking the compiled framework
objects (`build/JTest/*.o`) with the example object.
- `make run` is `make`'s `run` target: build the example and run it.
- **No external test runner is wired up** yet — the "runner" is `main()` in
`examples/example.cpp`, which hand-calls `execute(...)` for each group. This is
intentional for now; an automated/self-testing harness is on the roadmap.
### 5.1 Real run output (verified)
Running `make run` against `examples/example.cpp` (as it stands) prints:
```
🚧 Pending Test: ClassToTest::<anonymous>::should do the other thing
❌ Failed Test: ClassToTest::<anonymous>::should not do the bad thing reason: it did the bad thing
🔥 Unhandled exception running test: ClassToTest::<anonymous>::should throw an exception if we do the other bad thing what: Bad thing happen.
Total: 4, Passed: 1, Failed: 2, Skipped: 1
```
This is the current expected output: one passing, two failing, one pending.
---
## 6. Design notes & conventions to follow
These are the "gotchas" that save the next person hours:
1. **Headers include their own forward-declarations and then re-open the namespace
after including dependencies.** This pattern (declare `class Foo;` → include →
reopen `namespace`) appears throughout `include/JTest/`. Match it.
2. **`it`/`xit` return `TestBundle` (not `Test`)** today. This is a known rough
edge; the plan is to return `Test` and change `describe` to accept a variant.
Do not "fix" this unilaterally.
3. **The matcher `toBeTruthy/Equal/Null/False` and `toThrow` families use
`std::enable_if`/`std::is_convertible`/`std::is_invocable`** to compile only
when the relevant operation makes sense for `T`. When adding a matcher, mirror
this SFINAE style.
4. **Negation is `nevermore()`, not `not()`** — `not` is not usable as C++
method syntax. Keep that decision until it's revisited.
5. **Outcome = exception.** Don't invent a second "return a status" path; route
failures/pending through the existing exception types and the `execute`
catch-alls.
6. **Fully-qualified names use `::`** between path and label
(`ClassToTest::<anonymous>::should do the other thing`). Anonymous bundles
print as `<anonymous>`.
7. **`TestOptions` is a placeholder** — per-test options aren't wired yet.
8. **`Expectable::make_failure_message` only uses `operator<<` for `T`** — a
known limitation; failure text assumes a streamable type.
---
## 7. How to add a test (recipe for test authors)
1. Put your tests in `examples/` (or wherever the driver runs them).
2. Each "suite file" exposes one or more `TestResults <name>(const vector<string>&)`
functions, each calling `execute(describe(...), args)` and returning the
`TestResults`.
3. Register the suite in `main()` by adding `results += <name>(args);` and
uncommenting it (see `examples/example.cpp` lines around `main`).
4. Build & run: `make run`. Read the printed line per test, then the totals line.
---
## 8. Current State & Roadmap
This project is **early-stage**. The following come directly from `README.md`
(the authoritative TODO list) and the `TODO` comments in the code. Treat these
as the open work; do not assume they're done.
**Framework behaviour**
- Make `Expectable::toThrow` work with arguments, member functions, static
member functions, and possibly constructors/destructors.
- Report **all** errors at the end instead of printing inline (prerequisite for
parallel execution).
- Make JTest **self-testing** (possibly namespace magic to run a special
instance).
- **Mocks** and **Spies** — design, then add.
**API cleanups / design decisions**
- `Expectable<T>`:
- Clean up failure messages.
- Only use `ostream` in failure messages when `operator<<` is defined for `T`.
- Pick the final name for negation (`nevermore()` is the placeholder).
- `describe`/`xdescribe` / `it`/`xit`:
- Make `it`/`xit` return a `Test` instead of a `TestBundle`.
- Make `describe`/`xdescribe` accept a variant of `Test` or `TestBundle`.
- Make `TestOptions` actually useful.
- Set the label/path at the `it`/`describe` call site, not at execute time.
- A disabled bundle should report all child tests as skipped/disabled.
- **Execute tests in parallel.**
- Verify `before*`/`after*` inheritance: parent `before*` before child,
parent `after*` after child.
- When reporting an unhandled exception in a test, include the fully
qualified test name and label.
**Reporting**
- `DescribeOptions`: add `operator<<` and a `print(ostream&)`.
- `TestResults`: add (currently commented-out) `skipped`/`failed`/`errored`
vectors to hold detailed per-test info; consider making the counters private.
**Error reporting plumbing**
- `FailedExpectation`: figure out how to capture the label/path when creating an
expectation from an `expect<T>(T)` call (thread it out of the executing
`execute` method), then add it when the `FailedExpectation` is caught.
---
## 9. Quick glossary (for new team members)
- **TestBundle** — a node in the test tree (a group). Holds tests, child groups,
and the four lifecycle hooks.
- **Test** — a leaf: label + `test_fn` + disabled flag.
- **test_fn** — `std::function<void()>`; the body of an `it`.
- **configure_fn / maybe_configure_fn** — `std::function<void()>` / its
`optional`; the body of a lifecycle hook.
- **Expectable<T>** — the fluent matcher object returned by `expect(x)`.
- **TestResults** — the counters (`total/passed/failed/skipped`) accumulated as
tests run and printed at the end.
- **combine(a, b)** — chains two optional hooks into one.
---
## 10. How to keep this document fresh
This file is **living documentation**. When you change the public API in
`include/JTest/`, update §2 (vocabulary), §3 (layout), or §6 (conventions). When
you close a README `TODO`, remove it from §8. When you add a new header or a new
matcher, add it to §3.1 / §4.2.

53
tests/smoke.cpp Normal file
View File

@@ -0,0 +1,53 @@
// Smoke test for the JTest build system.
//
// This is a *genuine* self-check (not a demo). It exercises the public API the
// same way a user program would (describe/it/expect/toEqual/execute) and drives
// `ctest` so a broken or misconfigured build goes red. Unlike
// examples/example.cpp (which intentionally contains failing/skipping cases to
// showcase JTest's reporting and always returns 0), this test returns a
// non-zero exit code when an assertion fails, so CTest produces a real
// pass/fail.
//
// It links JTest and compiles without any external dependency, proving that the
// library archives, the include directory is exported transitively, and C++17
// is in effect.
#include <JTest/JTest.h>
#include <string>
namespace {
using JTest::describe;
using JTest::execute;
using JTest::expect;
using JTest::it;
using JTest::TestBundle;
using JTest::TestResults;
using std::string;
int add(int a, int b) {
return a + b;
}
} // namespace
int main() {
TestResults results = execute(
describe("JTest smoke", []() {
return TestBundle(
{
it("executes a passing arithmetic assertion", []() {
expect(add(2, 2)).toEqual(4);
}),
it("executes a passing string assertion", []() {
expect(string("JTest")).toEqual(string("JTest"));
}),
it("executes a second passing arithmetic assertion", []() {
expect(add(10, 20)).toEqual(30);
}),
},
JTest::DescribeOptions());
}));
// Non-zero exit when any test failed or errored, so CTest surfaces a real
// failure instead of silently passing.
return results.failed == 0 ? 0 : 1;
}