Tic-tac-toe implemented in Bash using a pseudo-functional style with self-contained TDD tools.
./test_tictactoe.shLast run: 2026-03-23 · Branch:
claude/analyze-test-coverage-KRPNY
[PASSED] - desc_should_return_passed
[PASSED] - desc_should_return_failed
[PASSED] - id_should_return_input_string
[PASSED] - id_should_return_input_number
[PASSED] - can_compare_multiple_word_strings
[PASSED] - testLength
[PASSED] - testHasMoreTrue
[PASSED] - testHasMoreFalse
[PASSED] - testTrueFalse
[PASSED] - testTrueFalseBranchTwo ← NEW
[PASSED] - testWhenTrue
[PASSED] - testWhenFalse
[PASSED] - testCanCreateGame
[PASSED] - testCanPlaceCross
[PASSED] - testCount
[PASSED] - testInitialNextPlayer
[PASSED] - testNextPlayerAfterCross
[PASSED] - testNextPlayerAfterNought
[PASSED] - testCannotPlaceCrossTwice
[PASSED] - testCanPlaceNoughtAfterCross
[PASSED] - testCannotPlaceNoughtIntoAnOccupiedSpace
[PASSED] - testCannotPlaceNoughtFirst ← NEW
[PASSED] - testSkip
[PASSED] - testSkipZero ← NEW
[PASSED] - testTake
[PASSED] - testTakeZero ← NEW
[PASSED] - testWinner
[PASSED] - testCanDetectWinnerAtFirstRow
[PASSED] - testCanDetectWinnerAtSecondRow
[PASSED] - testCanDetectWinnerAtThirdRow
[PASSED] - testSortVertically
[PASSED] - testSortVerticallyAlpha
[PASSED] - testSortItemMultipleOfFour ← NEW
[PASSED] - testSortItemEven ← NEW
[PASSED] - testSortItemOdd ← NEW
[PASSED] - testNearestLower4Multiple ← NEW
[PASSED] - testNearestHigher4Multiple ← NEW
[PASSED] - testCanDetectWinnerAtFirstColumn
[PASSED] - testCanDetectWinnerAtSecondColumn
[PASSED] - testCanDetectWinnerAtThirdColumn
[PASSED] - testFilterEven
[PASSED] - testCanDetectWinnerAtDiagonal1
[PASSED] - testFilterMultipleOfFour
[PASSED] - testCanDetectWinnerAtDiagonal2
[PASSED] - testCanDetectNoughtWinner ← NEW
[PASSED] - testNoWinnerYet ← NEW
[PASSED] - testDraw ← NEW
Function Tested? Tests
─────────────────────────────────────────────────────────
game YES testCanCreateGame
play (valid) YES testCanPlaceCross, testCanPlaceNoughtAfterCross
play (wrong turn) YES testCannotPlaceCrossTwice, testCannotPlaceNoughtFirst
play (occupied) YES testCannotPlaceNoughtIntoAnOccupiedSpace
nextPlayer (initial) YES testInitialNextPlayer
nextPlayer (after X) YES testNextPlayerAfterCross
nextPlayer (after O) YES testNextPlayerAfterNought
checkWinner (row 0) YES testCanDetectWinnerAtFirstRow
checkWinner (row 1) YES testCanDetectWinnerAtSecondRow
checkWinner (row 2) YES testCanDetectWinnerAtThirdRow
checkWinner (col 0) YES testCanDetectWinnerAtFirstColumn
checkWinner (col 1) YES testCanDetectWinnerAtSecondColumn
checkWinner (col 2) YES testCanDetectWinnerAtThirdColumn
checkWinner (diag ╲) YES testCanDetectWinnerAtDiagonal1
checkWinner (diag ╱) YES testCanDetectWinnerAtDiagonal2
checkWinner (player 2 wins) YES testCanDetectNoughtWinner ← NEW
checkWinner (no winner yet) YES testNoWinnerYet ← NEW
checkWinner (draw) YES testDraw ← NEW
winner YES testWinner
take (n > 0) YES testTake
take (n = 0) YES testTakeZero ← NEW
skip (n > 0) YES testSkip
skip (n = 0) YES testSkipZero ← NEW
sortVertically YES testSortVertically, testSortVerticallyAlpha
sortItem (mult. of 4) YES testSortItemMultipleOfFour ← NEW
sortItem (even) YES testSortItemEven ← NEW
sortItem (odd) YES testSortItemOdd ← NEW
nearestLower4Multiple YES testNearestLower4Multiple ← NEW
nearestHigher4Multiple YES testNearestHigher4Multiple ← NEW
filter (isEven) YES testFilterEven
filter (isMultipleOfFour) YES testFilterMultipleOfFour
count YES testCount
length YES testLength
hasMore (true) YES testHasMoreTrue
hasMore (false) YES testHasMoreFalse
when (true) YES testWhenTrue
when (false) YES testWhenFalse
trueFalse (branch 1) YES testTrueFalse
trueFalse (branch 2) YES testTrueFalseBranchTwo ← NEW
─────────────────────────────────────────────────────────
Total functions: 39 Covered: 39 / 39 Coverage: 100%
checkWinner returns an empty string for both "no winner yet" and "draw".
Add a helper that checks if the board is full to distinguish the two cases:
function isBoardFull {
local -r board=$(read_input)
[ $(echo "$board" | count "$NONE") -eq 0 ]
}Printing the raw string 1 _ _ 2 _ _ _ _ _ is hard to read during a game session.
A thin formatter would make the project immediately more usable:
function printBoard {
local -r b=($(read_input))
echo "+---+---+---+"
for row in 0 1 2; do
printf "| %s | %s | %s |\n" ${b[$((row*3))]} ${b[$((row*3+1))]} ${b[$((row*3+2))]}
echo "+---+---+---+"
done
}Wrap game/play/checkWinner into a gameLoop that reads coordinates from stdin:
function gameLoop {
local board=$(game)
while true; do
read -r player row col
board=$(echo "$board" | play "$player" "$row" "$col")
echo "$board" | printBoard
local w=$(echo "$board" | checkWinner)
[ -n "$w" ] && echo "Winner: $w" && break
done
}The heavy use of subshells ($(...)) and recursive calls in filter/winner
adds noticeable overhead. Three concrete options:
| Approach | Effort | Speed gain |
|---|---|---|
Cache $(nextPlayer) per test |
Low | Small |
Replace recursive filter with a loop |
Medium | Large |
Use [[ ... ]] instead of [ ... ] |
Low | Small |
ORDER=3 is a constant, but sortItem's math already generalises.
Expose it as an argument to game and test for a 4×4 board to prove
the invariant holds beyond 3×3.
assert_that $(expr) | is "expected"The assert_that / is pair models an expectation builder entirely in Bash.
assert_that evaluates an expression and writes its output; is reads from
stdin and compares. This avoids global state and is composable.
Reusable in any Bash project that has functions returning values through
echo. Examples: config parsers, file processors, CLI tools.
test_tools.sh is sourced before game code. The framework itself has tests
(desc_should_return_passed, id_should_return_input_string, …).
This "test the tests" bootstrap gives confidence that assertion failures are
real, not framework bugs.
Reuse tip: copy test_tools.sh as a minimal dependency into any shell
project that cannot install bats or shunit2.
Game state is a string (1 _ _ 2 _ _ _ _ _). Tests assert the whole board
string, not just one cell. This documents the full observable effect of each
operation and catches accidental side effects.
Reuse tip: wherever your Bash function produces a structured string (CSV line, JSON fragment, log record), test the exact output string. Avoid partial matching unless intentional.
testSkipZero, testTakeZero, testNearestLower4Multiple test n = 0 and
border values around multiples. Standard boundary-value analysis applied to
arithmetic Bash functions.
Reuse tip: every $((...)) expression in shell scripts has edge cases
(integer division, modulo of 0, negative numbers). Always add a test for
n = 0, n = 1, and the first value that crosses a branch.
filter isEven and filter isMultipleOfFour pass a function name as a
string. This simulates higher-order functions in Bash. Tests for sortItem
cover all three branches of the dispatching logic independently.
Reuse tip: if a Bash function accepts a callback name, write one test per callback variant. Mock the callback with a trivial echo function to isolate the routing logic from the callback logic.
testCanDetectNoughtWinner, testDraw, and testNoWinnerYet compose multiple
play calls into a scenario and assert the final state. This is
scenario-based / black-box testing layered on top of unit tests.
Reuse tip: after unit-testing all primitives, write at least three scenario tests: happy path, edge case, failure case. For a game engine, that maps to someone wins, draw, invalid input.
- Start functional, stay functional. You chose to avoid
if/elsein favour oftrueFalse+when, and avoided global mutable state entirely. This was a deliberate exploration of how far pure composition goes in Bash. - TDD from the very first function. The test framework was built before (or alongside) the game code. The game functions were shaped by what was easy to assert, not the other way around.
- Pipe as the data bus. Every function reads from stdin and writes to
stdout. This mirrors the Unix philosophy and allows arbitrary chaining:
game | play 1 0 0 | play 2 1 1 | checkWinner. sortVerticallyas the cleverness peak. The index-math trick insortItem(using nearest multiples of 4) is the most non-obvious piece. You probably spent the most design time here.
-
Add a
checkDrawfunction that returns"draw"when the board is full andcheckWinneris empty. One test needed:testCheckDraw. -
Write a game loop script (
play.sh) that callsgameLoopand lets two humans play in a terminal — the engine is complete, only the UI wrapper is missing. -
Write the tutorial (listed in TODO). The commit history and test names already tell the story; the tutorial would be: one test → one function → next test. The pipe-based style is genuinely unusual and worth documenting.
-
Publish to GitHub Pages or similar with an automatically updated test badge. The CI pipeline would be:
bash test_tictactoe.sh | tee results.txt, with the result embedded in the README via a GitHub Action. -
Profile the test run (
time bash test_tictactoe.sh) and identify the slowest three tests. Replace the recursivefilterwith a loop and measure the speedup — this directly addresses the "Test execution is slow" TODO. -
Generalise to N×N — try
ORDER=4and addtestCanDetectWinnerAtRow_4x4. The sortItem math already hints at a general formula.
| Project | URL | Notes |
|---|---|---|
| ssledz/bash-fun | https://github.com/ssledz/bash-fun | The most complete FP library for Bash: map, filter, foldl, foldr, zip, maybe (Option type), try/catch, lambda syntax (λ). Closest reference for what this project is exploring — see also the slide deck |
| spencertipping/bash-lambda | https://github.com/spencertipping/bash-lambda | Extreme end: adds real anonymous functions, closures, partial application, futures, and mark/sweep GC to Bash. Useful for understanding what closures would look like if Bash had them natively |
| sharkdp/shell-functools | https://github.com/sharkdp/shell-functools | map, filter, foldl, sort_by, take_while as standalone UNIX commands composable via pipes — models the exact pipe-as-composition pattern used here |
| timonson/bashFunc | https://github.com/timonson/bashFunc | Lighter-weight: map, filter, reduce in pure Bash; good as a minimal reference implementation |
| Project | URL | Notes |
|---|---|---|
| bats-core/bats-core | https://github.com/bats-core/bats-core | Community-maintained successor to bats; TAP-compliant; TDD RED→GREEN→REFACTOR tutorial built-in; ecosystem includes bats-assert, bats-mock. Most widely used for TDD-style Bash testing |
| bash_unit | https://github.com/pgrange/bash_unit | Tests written in pure Bash (no DSL); setup/teardown hooks; closest analog to Python unittest. Best fit for a project where both tests and implementation are plain shell |
| shUnit2 | https://github.com/kward/shunit2 | xUnit-style (assertEquals); familiar to JUnit/PyUnit users |
| ShellSpec | https://shellspec.info/ | BDD-style (RSpec for Bash); built-in mocking, parameterized tests, coverage, parallel execution |
| dodie/testing-in-bash | https://github.com/dodie/testing-in-bash | Side-by-side comparison: same tests written in bats, shunit2, bash_unit, ShellSpec, shpec — best starting point for choosing a framework |
| Project | URL | Notes |
|---|---|---|
| justineuro/tic-tac-toe | https://github.com/justineuro/tic-tac-toe | Two-player, terminal colours; reference for board rendering |
| TicTacToe-Players (minimax) | https://github.com/andrewgph/TicTacToe-Players | Recursive minimax AI in Bash; closest to this project's recursive style |
Gap worth noting: existing Bash tic-tac-toe implementations are all imperative with no tests. A functionally-styled, TDD-driven implementation like this one fills a real gap in the space.
| Resource | URL | Notes |
|---|---|---|
| Functional Programming in Bash (2024) | https://scalastic.io/en/bash-functional-programming/ | Covers pure functions, immutability, lazy evaluation, higher-order functions — with concrete examples |
| FP in Bash — Medium | https://joydeep31415.medium.com/functional-programming-in-bash-145b6db336b7 | Approachable intro to pipe-based composition and passing function names as arguments |
| Collection Pipeline — Martin Fowler | https://martinfowler.com/articles/collection-pipeline/ | Canonical article on filter/map/reduce; Unix pipes directly implement this pattern |
| FP is Like Unix Pipelines | https://alvinalexander.com/scala/fp-book/how-functional-programming-is-like-unix-pipelines/ | Explains the direct conceptual mapping between pipes and function composition |
| I want my Bash Pipe | https://dev.to/xtofl/i-want-my-bash-pipe-34i2 | Practical patterns for chaining transformations in shell |
| Testing Bash with Bats — Opensource.com | https://opensource.com/article/19/2/testing-bash-bats | Solid introduction to the TDD cycle with bats; applicable approach to this project |
stdin ──► game ──► play ──► play ──► ... ──► checkWinner ──► stdout
│ │ │
│ nextPlayer (validation) │
│ count / when / trueFalse │
│ sortVertically
│ filter isEven
│ filter isMultipleOfFour
│ winner (recursive)
└─ board: space-separated 9-cell string (immutable)
All state flows through stdout. No global variables are mutated after init.
- Add
checkDrawfunction and tests - Document the process of analysis and implementation (tutorial)
- Build interactive game loop (
play.sh) - Fix test execution speed (profile + replace recursive
filterwith loop) - Generalise to N×N board
- Add CI badge with auto-updated test results