Sitemap

Behaviour Driven Developing with Colibri — a Swift Type Language

16 min readMay 8, 2025

--

BDD Tests

Few days ago I presented Colibri, a declarative language inside Swift’s type system. To keep that article short I did not include a crucial part: How to do Behaviour Driven Design with it. This article will rectify this and focus on BDD.

We will use Quick and Nimble for our test codes / specifications.

Let’s say we want to create a small project management tool, where a project has a title and can have any number of tasks. Each task has a title, a due date and a state. Users can record the amount of time they spent on any of those tasks.

We add a simple Project type with just the title and an id

public struct Project: Identifiable {
public let id : UUID
public let title: String

public init(title: String) {
self.init(UUID(), title)
}
private init(_ id:UUID,_ title: String) {
self.id = id
self.title = title
}
}

and add the test to verify it

class ProjectSpecs:QuickSpec {
override class func spec() {
var project:Project!

describe("Project") {
beforeEach {
project = Project(title: "Party")
}
afterEach {
project = nil
}
context("newly created") {
it("has an id" ) { expect(project.id ).toNot(beNil() ) }
it("has a title") { expect(project.title).to (equal("Party")) }
}
}
}
}

This contains two tests: Project, newly created, has an id and Project, newly created, has a title.

Now we want to change the title Colibri-style.
We add an enum Project.Change as DSL. And an alter method, that, for now, just returns the unchanged Project object, to keep everything compilable and the tests executable.

public struct Project: Identifiable {
public let id : UUID
public let title: String

public enum Change {
case setting(Setting); public enum Setting {
case title(to: String)
}
}

public init(title: String) {
self.init(UUID(), title)
}
private init(_ id:UUID,_ title: String) {
self.id = id
self.title = title
}

public func alter(by c:Change) -> Self {
return self
}
}

The Change DSL encodes the command .setting(.title(to:<new title>))

Now we can write the BDD test for changing the title

class ProjectSpecs:QuickSpec {
override class func spec() {
var project:Project!

describe("Project") {
beforeEach {
project = Project(title: "Party")
}
afterEach {
project = nil
}
context("newly created") {
// ...
}
context("change title") {
var p0:Project!
beforeEach {
p0 = project.alter(by: .setting(.title(to: "Party — deluxe!")))
}
afterEach {
p0 = nil
}
it("has new title" ) { expect(p0.title).to(equal("Party — deluxe!")) }
it("has unchanged id") { expect(p0.id ).to(equal(project.id) ) }
}
}
}
}

In Project, change title context we alter the project by setting a new title: p0 = project.alter(by: .setting(.title(to: “Party — deluxe!”)))
And then we test two things: The title has changed and everything else has stayed the same, in this case that the id is unchanged.
The tests are Project, change title, has new title and Project, change title, has unchanged id.

In fact this is the Scientific Method in action: make one single change and observe what has changed and what not.

A Project consists of several ProjectTasks. A ProjectTask has an id, a title, a due date and a state. We add all these as property members and define a ProjectTask.Change DSL to set title, due date and state to new values. alter will return an unchanged self, just to keep it compilable and let us write the BDD specification.

public struct ProjectTask: Identifiable, Equatable {
public let id : UUID
public let title : String
public let dueDate: DueDate
public let state : TaskState

public enum Change {
case setting(Setting); public enum Setting {
case title (to: String)
case dueDate(to: DueDate)
case state (to:TaskState)
}
}

public init(title: String) {
self.init(UUID(), title, .unknown, .unfinished)
}
private init(_ id: UUID,_ title: String,_ dueDate:DueDate,_ state:TaskState) {
self.id = id
self.title = title
self.dueDate = dueDate
self.state = state
}

public func alter(by change: Change) -> Self {
return self
}
}

public enum DueDate: Equatable {
case unknown
case date(Date)
}

public enum TaskState: Equatable {
case unfinished
case inProgress
case finished
}
class ProjectTaskSpecs: QuickSpec {
override class func spec() {
describe("ProjectTask") {
var projectTask: ProjectTask!

beforeEach {
projectTask = ProjectTask(title: "Buy Beer")
}
afterEach {
projectTask = nil
}
context("newly created") {
it("has an id" ) { expect(projectTask.id ).notTo(beNil() ) }
it("has a title" ) { expect(projectTask.title ).to (equal("Buy Beer") ) }
it("has unknown due date") { expect(projectTask.dueDate).to (equal(.unknown) ) }
it("has unfinished state") { expect(projectTask.state ).to (equal(.unfinished)) }
}
context("setting title") {
var t0: ProjectTask!
beforeEach {
t0 = projectTask.alter(by: .setting(.title(to: "Buy Beer ASAP!")))
}
afterEach {
t0 = nil
}
it("has new title" ) { expect(t0.title ).to(equal("Buy Beer ASAP!")) }
it("has unchanged id" ) { expect(t0.id ).to(equal(projectTask.id)) }
it("has unchanged due date") { expect(t0.dueDate).to(equal(projectTask.dueDate)) }
it("has unchanged state" ) { expect(t0.state ).to(equal(projectTask.state)) }
}
context("setting due date") {
var t0: ProjectTask!
var d0: Date!
afterEach {
t0 = nil
d0 = nil
}
context("to tomoorow noon") {
beforeEach {
d0 = Calendar.current.date(byAdding: .hour, value: 12, to: Calendar.current.startOfDay(for: .now))!
t0 = projectTask.alter(by: .setting(.dueDate(to:.date(d0))))
}
it("has due date tomorrow noon") { expect(t0.dueDate).to(equal(.date(d0))) }
it("has unchanged title" ) { expect(t0.title ).to(equal(projectTask.title)) }
it("has unchanged id" ) { expect(t0.id ).to(equal(projectTask.id)) }
it("has unchanged state" ) { expect(t0.state ).to(equal(projectTask.state)) }
}
context("to unknown") {
beforeEach {
d0 = Calendar.current.date(byAdding: .hour, value: 12, to: Calendar.current.startOfDay(for: .now))!
t0 = projectTask.alter(by: .setting(.dueDate(to:.date(d0))))
t0 = t0.alter(by: .setting(.dueDate(to: .unknown)))
}
it("has unknown due date") { expect(t0.dueDate).to(equal(.unknown)) }
it("has unchanged title" ) { expect(t0.title ).to(equal(projectTask.title)) }
it("has unchanged id" ) { expect(t0.id ).to(equal(projectTask.id)) }
it("has unchanged state" ) { expect(t0.state ).to(equal(projectTask.state)) }
}
}
context("setting state") {
var t0: ProjectTask!
afterEach {
t0 = nil
}
context("to in progress") {
beforeEach {
t0 = projectTask.alter(by: .setting(.state(to: .inProgress)))
}
it("has state in progress" ) { expect(t0.state ).to(equal(.inProgress)) }
it("has unchanged title" ) { expect(t0.title ).to(equal(projectTask.title)) }
it("has unchanged id" ) { expect(t0.id ).to(equal(projectTask.id)) }
it("has unchanged due date") { expect(t0.dueDate).to(equal(projectTask.dueDate)) }
}
context("to finished") {
beforeEach {
t0 = projectTask.alter(by: .setting(.state(to: .finished)))
}
it("has state finished" ) { expect(t0.state ).to(equal(.finished)) }
it("has unchanged title" ) { expect(t0.title ).to(equal(projectTask.title)) }
it("has unchanged id" ) { expect(t0.id ).to(equal(projectTask.id)) }
it("has unchanged due date") { expect(t0.dueDate).to(equal(projectTask.dueDate)) }
}
context("to unfinished") {
beforeEach {
t0 = projectTask.alter(by: .setting(.state(to: .finished)))
t0 = t0.alter(by: .setting(.state(to: .unfinished)))
}
it("has state unfinished" ) { expect(t0.state ).to(equal(.unfinished)) }
it("has unchanged title" ) { expect(t0.title ).to(equal(projectTask.title)) }
it("has unchanged id" ) { expect(t0.id ).to(equal(projectTask.id)) }
it("has unchanged due date") { expect(t0.dueDate).to(equal(projectTask.dueDate)) }
}
}
}
}
}

Again the tests follow the same simple principle: make one change and observe that the expected property is updated. Also observe that everything else stays the same.
As we aren’t changing anything in alter yet, some of the test will fail. By expanding alter on a change value at a time, we make sure all tests become green again. First we deal with setting the title to a new value:

public struct ProjectTask: Identifiable, Equatable {
// ...

public func alter(by change: Change) -> Self {
return switch change {
case let .setting(.title(to: t)): Self(id, t, dueDate, state)
default: self
}
}
}

Running the tests we see that test ProjectTask, setting title, has a new title succeeds now.

We add the setting of a due date:

public struct ProjectTask: Identifiable, Equatable {
// ...

public func alter(by change: Change) -> Self {
return switch change {
case let .setting(.title (to: t)): Self(id, t , dueDate, state)
case let .setting(.dueDate(to: d)): Self(id, title, d , state)
default: self
}
}
}

Running the tests confirms: ProjectTask, setting due date, to tomorrow noon, has due date tomorrow noon is green now.

Next setting the state:

public struct ProjectTask: Identifiable, Equatable {
// ...

public func alter(by change: Change) -> Self {
return switch change {
case let .setting(.title (to: t)): Self(id, t , dueDate, state)
case let .setting(.dueDate(to: d)): Self(id, title, d , state)
case let .setting(.state (to: s)): Self(id, title, dueDate, s )
}
}
}

This turns the tests ProjectTask, setting state, to in progress, has state in progress and ProjectTask, setting state, to finished, has state finished green.
Note, that in a real world application there is a more complicated state machine that determines what state can be followed by a certain other state. This adds many more tests that I keep out in this example for brevity.

We return to Project and change it to keep a lists of tasks.

public struct Project: Identifiable {
public let id : UUID
public let title: String
public let tasks: [ProjectTask]

public enum Change {
// ...
case adding(Adding); public enum Adding {
case task(ProjectTask)
}
}

public init(title: String) {
self.init(UUID(), title, [])
}
private init(_ id: UUID,_ title: String,_ tasks: [ProjectTask]) {
self.id = id
self.title = title
self.tasks = tasks
}

public func alter(by c:Change) -> Self {
return switch c {
case let .setting(.title(to: t)): Self(id, t , tasks )
case let .adding (.task(t)) : Self(id, title, tasks + [t])
}
}
}

The Quick and Nimble tests looks as follows:

class ProjectSpecs:QuickSpec {
override class func spec() {
var project:Project!

describe("Project") {
// ...

context("adding task") {
var p0: Project!
var t0: ProjectTask!
beforeEach {
t0 = ProjectTask(title: "Buy Beer")
p0 = project.alter(by: .adding(.task(t0)))
}
afterEach {
p0 = nil
t0 = nil
}
it("has 1 task") { expect(p0.tasks).to(equal([t0])) }
}
}
}
}

Writing those tests I start to wonder: what happens if I add the same test twice. Let’s explore this question by implementing a test. We do this by adding a sub-context to the Project, adding task context

class ProjectSpecs:QuickSpec {
override class func spec() {
var project:Project!

describe("Project") {
// ...

context("adding task") {
var p0: Project!
var t0: ProjectTask!
beforeEach {
t0 = ProjectTask(title: "Buy Beer")
p0 = project.alter(by: .adding(.task(t0)))
}
afterEach {
p0 = nil
t0 = nil
}
it("has 1 task") { expect(p0.tasks).to(equal([t0])) }

context("adding same task again") {
var p1: Project!
beforeEach {
p1 = p0.alter(by: .adding(.task(t0)))
}
afterEach {
p1 = nil
}
it("has 1 task") { expect(p1.tasks).to(equal([t0])) }
}
}
}
}
}

And indeed the test will fail, as p1.tasks contains the same task twice, but that is easily fixed: Before we add the task we filter tasks so that only tasks with a different id are left.

public struct Project: Identifiable {
// ...

public func alter(by c:Change) -> Self {
return switch c {
case let .setting(.title(to: t)): Self(id, t , tasks )
case let .adding (.task(t)) : Self(id, title, tasks.filter{$0.id != t.id} + [t])
}
}
}

The test turns green.

After adding tasks it makes sense to remove tasks from Project. First we extend Project’s Change DSL

public struct Project: Identifiable {
//...
public enum Change {
// ...
case removing(Removing); public enum Removing {
case task(ProjectTask)
}
}
// ...
}

And as seen before we change alter to return unchanged self to make it compilable and allow us to run the tests

public struct Project: Identifiable {
// ...
public func alter(by c:Change) -> Self {
return switch c {
case let .setting(.title(to: t)): Self(id, t , tasks )
case let .adding (.task(t)) : Self(id, title, tasks.filter{$0.id != t.id} + [t])
case let .removing(.task(t)) : self
}
}
}

As test we add three tasks and remove one of them

class ProjectSpecs:QuickSpec {
override class func spec() {
var project:Project!

describe("Project") {
// ...
context("removing task") {
var p0: Project!
var t0: ProjectTask!
var t1: ProjectTask!
var t2: ProjectTask!

beforeEach {
t0 = ProjectTask(title: "Buy Beer")
t1 = ProjectTask(title: "Buy Chips")
t2 = ProjectTask(title: "Order Pizza")

p0 = project
.alter(by:.adding(.task(t0)))
.alter(by:.adding(.task(t1)))
.alter(by:.adding(.task(t2)))

p0 = p0.alter(by: .removing(.task(t1)))
}
afterEach {
p0 = nil
t0 = nil
t1 = nil
t2 = nil
}
it("has 2 tasks" ) { expect(p0.tasks).to(equal([t0,t2]) ) }
it("has unchanged id") { expect(p0.id ).to(equal(project.id)) }
}
}
}
}

Project, removing task, has 2 tasks fails, as currently no task is removed in alter. We change that now.

public struct Project: Identifiable {
// ...
public func alter(by c:Change) -> Self {
return switch c {
case let .setting(.title(to: t)): Self(id, t , tasks )
case let .adding (.task(t)) : Self(id, title, tasks.filter{$0.id != t.id} + [t])
case let .removing(.task(t)) : Self(id, title, tasks.filter{$0.id != t.id} )
}
}
}

No surprises there.

We want to track the time spent on any ProjectTask, there-for we add the data type TimeSlip:

public struct TimeSlip: Identifiable, Equatable, Codable {
public let id : UUID
public let start: Date
public let stop : Date?

public enum Change {
case setting(Setting); public enum Setting {
case stop(to:Date)
}
}

public init(start: Date) {
self.init(UUID(), start, nil)
}
private init (_ id:UUID,_ start:Date,_ end:Date?) {
self.id = id
self.start = start
self.stop = end
}

public func alter(_ c:Change) -> Self {
return switch c {
case let .setting(.stop(to: d)): Self(id, start, d)
}
}
}

It has an id, a start date and an optional stop date. The Change DSL defines a command to set the stop date, .setting(.stop(to:<new date>)). The start date is passed in during creation of a TimeSlip.

TimeSlip’s BDD tests:

class TimeSlipSpecs: QuickSpec {
override class func spec() {
describe("TimeSlip") {
var timeSlip:TimeSlip!

var d0:Date!
beforeEach {
d0 = .now
timeSlip = TimeSlip(start: d0)
}
afterEach {
d0 = nil
timeSlip = nil
}

context("newly created") {
it("has an id" ) { expect(timeSlip.id ).toNot(beNil() ) }
it("has start date now") { expect(timeSlip.start).to (equal(d0)) }
it("has no stop date" ) { expect(timeSlip.stop ).to (beNil() ) }
}
context("setting stop date") {
var t0: TimeSlip!
var d1: Date!
beforeEach {
d1 = Calendar.current.date(byAdding: .hour, value: 2, to: d0)!
t0 = timeSlip.alter(.setting(.stop(to: d1)))
}
afterEach {
t0 = nil
d1 = nil
}
it("has an stop date" ) { expect(t0.stop ).to(equal(d1) ) }
it("has an unchanged id" ) { expect(t0.id ).to(equal(timeSlip.id) ) }
it("has an unchanged start date") { expect(t0.start).to(equal(timeSlip.start)) }
}
}
}
}

ProjectTask needs a list of TimeSlips and commands to start and stop time recording

public struct ProjectTask: Identifiable {
public let id : UUID
public let title : String
public let dueDate : DueDate
public let state : TaskState
public let timeSlips: [TimeSlip]

public enum Change {
case setting(Setting); public enum Setting {
case title (to: String)
case dueDate(to: DueDate)
case state (to:TaskState)
}
case recording(Record); public enum Record {
case start
case stop
}
}

public init(title: String) {
self.init(UUID(), title, .unknown, .unfinished, [])
}
private init(_ id: UUID,_ title: String,_ dueDate:DueDate,_ state:TaskState,_ timeSlips:[TimeSlip]) {
self.id = id
self.title = title
self.dueDate = dueDate
self.state = state
self.timeSlips = timeSlips
}

public func alter(by change: Change) -> Self {
return switch change {
case let .setting(.title (to: t)): Self(id, t , dueDate, state, timeSlips )
case let .setting(.dueDate(to: d)): Self(id, title, d , state, timeSlips )
case let .setting(.state (to: s)): Self(id, title, dueDate, s , timeSlips )
case .recording(.start) : Self(id, title, dueDate, state, startRecoding())
case .recording(.stop) : Self(id, title, dueDate, state, stopRecoding() )
}
}
private func startRecoding() -> [TimeSlip] {
if let last = timeSlips.last,
last.stop == nil {
return timeSlips
}
return timeSlips + [TimeSlip(start: .now)]
}
private func stopRecoding() -> [TimeSlip] {
if let last = timeSlips.last,
last.stop == nil {
return timeSlips.filter { $0.id != last.id } + [last.alter(.setting(.stop(to: .now)))]
}
return timeSlips
}
}

Here we use helper functions startRecording and stopRecording, as we ensure that no two slips can be started at the same time and that the stop command will only alter the task if the last slip isn’t stopped yet.

The tests are nested several level to check double starting doesn’t lead to several unstopped slips and similar edge cases.

class ProjectTaskSpecs: QuickSpec {
override class func spec() {
describe("ProjectTask") {
var projectTask: ProjectTask!

beforeEach {
projectTask = ProjectTask(title: "Buy Beer")
}
afterEach {
projectTask = nil
}
// ...
context("record time") {
var t0: ProjectTask!
var d0: Date!
beforeEach {
d0 = .now
}
afterEach {
d0 = nil
t0 = nil
}
context("start") {
beforeEach {
t0 = projectTask.alter(by: .recording(.start))
}
it("records start in last timeslip") { expect(t0.timeSlips.last!.start).to(beCloseTo(d0)) }
it("has 1 timeslip" ) { expect(t0.timeSlips ).to(haveCount(1) ) }
it("records no stop date" ) { expect(t0.timeSlips.last!.stop ).to(beNil() ) }
context("start again") {
var t1: ProjectTask!
beforeEach {
t1 = t0.alter(by: .recording(.start))
}
it("doesn't add timeslip") { expect(t1.timeSlips).to(equal(t0.timeSlips)) }
}
context("stop") {
var d1: Date!
var t1: ProjectTask!
beforeEach {
d1 = .now
t1 = t0.alter(by: .recording(.stop))
}
afterEach {
d1 = nil
t1 = nil
}
it("records stop in last timeslip") { expect(t1.timeSlips.last!.stop).to(beCloseTo(d1)) }
context("start") {
var d2: Date!
var t2: ProjectTask!
beforeEach {
d2 = .now
t2 = t1.alter(by: .recording(.start))
}
afterEach {
d2 = nil
t2 = nil
}
it("has first timeslip start" ) { expect(t2.timeSlips[0].start).to(beCloseTo(d0)) }
it("has second timeslip start" ) { expect(t2.timeSlips[1].start).to(beCloseTo(d1)) }
it("has first timeslip stop" ) { expect(t2.timeSlips[0].stop ).to(beCloseTo(d2)) }
it("hasn't second timeslip stop") { expect(t2.timeSlips[1].stop ).to(beNil()) }
context("stop") {
var d3: Date!
var t3: ProjectTask!
beforeEach {
d3 = .now
t3 = t2.alter(by: .recording(.stop))
}
afterEach {
d3 = nil
t3 = nil
}
it("has first timeslip start" ) { expect(t3.timeSlips[0].start).to(beCloseTo(d0)) }
it("has second timeslip start") { expect(t3.timeSlips[1].start).to(beCloseTo(d1)) }
it("has first timeslip stop" ) { expect(t3.timeSlips[0].stop ).to(beCloseTo(d2)) }
it("has second timeslip stop" ) { expect(t3.timeSlips[1].stop ).to(beCloseTo(d3)) }
}
}
context("stop again") {
var t2:ProjectTask!
beforeEach {
t2 = t1.alter(by: .recording(.stop))
}
afterEach {
t2 = nil
}
it("doesn't change timeslips") { expect(t2.timeSlips).to(equal(t1.timeSlips)) }
}
}
}
context("stop") {
beforeEach {
t0 = projectTask.alter(by: .recording(.stop))
}
it("doesn't add timeslip") { expect(t0.timeSlips).to(beEmpty()) }
}
}
}
}
}

A newly added model, AppState, will hold our app’s projects. AppState.Change encodes three commands: .adding(.project(<new project>)), .removing(.project(<existing project>)) and .updating(.project(<existing project>))

public struct AppState {
public enum Change {
case adding(Add); public enum Add {
case project(Project)
}
case removing(Remove); public enum Remove {
case project(Project)
}
case updating(Update); public enum Update {
case project(Project)
}
}

public let projects: [Project]

public init() {
self.init([])
}

private init(_ projects: [Project]) {
self.projects = projects
}

public func alter(by cs:Change...) -> Self { cs.reduce(self) { $0.alter(by:$1) } }
private func alter(by c:Change) -> Self {
return switch c {
case let .adding (.project(p)): Self(projects.filter{ $0.id != p.id } + [p])
case let .removing(.project(p)): Self(projects.filter{ $0.id != p.id } )
case let .updating(.project(p)): update(project: p)
}
}

private func update(project p:Project) -> Self {
if let idx = projects.firstIndex(where: { $0.id == p.id }) {
var projects = projects
projects[idx] = p
return Self(projects)
}
return self
}
}

AppState’s tests:

final class AppStateSpec: QuickSpec {
override class func spec() {
var appState: AppState!
describe("AppState") {
beforeEach {
appState = AppState()
}
afterEach {
appState = nil
}
context("Newly Created") {
it("has empty projects") { expect(appState.projects).to(beEmpty()) }
}
context("Add Project") {
var s0: AppState!
var p0: Project!
beforeEach {
p0 = Project(title:"World Domination")
s0 = appState.alter(by: .adding(.project(p0)))
}
afterEach {
p0 = nil
s0 = nil
}
it("has one project") { expect(s0.projects).to(equal([p0])) }
context("add same project again") {
var s1: AppState!
beforeEach {
s1 = s0.alter(by: .adding(.project(p0)))
}
afterEach{
s1 = nil
}
it("doesn't change state's projects") { expect(s1.projects).to(equal(s0.projects)) }
}
context("Remove Project") {
var s1: AppState!
beforeEach {
s1 = s0.alter(by: .removing(.project(p0)))
}
afterEach {
s1 = nil
}
it("has no projects") { expect(s1.projects).to(beEmpty()) }
}
}
context("Update") {
var s0: AppState!
var p0: Project!
var p1: Project!
var p2: Project!
beforeEach {
p0 = Project(title: "0")
p1 = Project(title: "1")
p2 = Project(title: "2")
s0 = appState.alter(
by: .adding(.project(p0)),
.adding(.project(p1)),
.adding(.project(p2))
)
}
afterEach {
s0 = nil
p0 = nil
p1 = nil
p2 = nil
}
context("Existing Project") {
var p3: Project!
var s1: AppState!
beforeEach {
p3 = p1.alter(by:.setting(.title(to: "one")))
s1 = s0.alter(by: .updating(.project(p3)))
}
afterEach {
p3 = nil
s1 = nil
}
it("has updated project") { expect(s1.projects).to(equal([p0,p3,p2])) }
}
context("Nonexisting Project") {
var p3: Project!
var s1: AppState!
beforeEach {
p3 = Project(title:"3")
s1 = s0.alter(by: .updating(.project(p3)))
}
afterEach {
p3 = nil
s1 = nil
}
it("does not change existing projects") { expect(s1.projects).to(equal(s0.projects)) }
}
}
}
}
}

Each time a project is added, removed or updated a new AppState value is created. To later create an app that is usable through an UI we need a place where to store the current AppState — an AppStore.


final public class AppStore {

public private(set) var state : AppState { didSet { persister.persist(appState: state); notify() } }
private var subscribers: [AppStoreSubscriber]
private let persister : AppPersisting

public init(persister: AppPersisting) {
self.state = persister.loadAppState()
self.subscribers = []
self.persister = persister
}

public func change(_ c:AppState.Change) {
state = state.alter(by: c)
}

public func subscribe(subscriber s:AppStoreSubscriber) {
subscribers.append(s)
}

private func notify() {
subscribers.forEach {
$0.updated(store: self)
}
}
}

public protocol AppStoreSubscriber {
func updated(store:AppStore)
}

Each time the state is changed, a persister object is used to save it to disk.

public protocol AppPersisting {
func loadAppState() -> AppState
func persist(appState:AppState)
func destroy()
}

public final class DiskAppPersister: AppPersisting {
private let pathInDocuments:String
private let fileManager:FileManager

public init(pathInDocuments: String = "state.json", fileManager: FileManager = .default) {
self.pathInDocuments = pathInDocuments
self.fileManager = fileManager
}
public func loadAppState() -> AppState {
loadAppStateFromStore(pathInDocuments:pathInDocuments, fileManager:fileManager)
}
public func persist(appState: AppState) {
TimeTrackerModels.persist(state:appState, at:pathInDocuments, with:fileManager)
}
public func destroy() {
destroyStore(at:pathInDocuments, with:fileManager)
}
}

private func loadAppStateFromStore(pathInDocuments:String, fileManager:FileManager) -> AppState {
do {
let fu = fileURL(pathInDocuments:pathInDocuments,fileManager:fileManager)
return try JSONDecoder().decode(AppState.self,from:try Data(contentsOf:fu))
} catch {
return AppState()
}
}

private func persist(state:AppState, at pathInDocuments:String, with fileManager:FileManager) {
let encoder = JSONEncoder()
#if DEBUG
encoder.outputFormatting = .prettyPrinted
#endif
try? encoder
.encode(state)
.write(to:fileURL(pathInDocuments:pathInDocuments, fileManager:fileManager))
}

private func fileURL(pathInDocuments:String,fileManager:FileManager) -> URL {
return URL.documentsDirectory.appending(component:pathInDocuments)
}

private func destroyStore(at pathInDocuments:String, with fileManager:FileManager) {
try? fileManager.removeItem(at:fileURL(pathInDocuments:pathInDocuments, fileManager:fileManager))
}

The AppStore specifications:

final class AppStoreSpec: QuickSpec {
override class func spec() {
describe("AppStore") {
var appStore : AppStore!
var persister: DiskAppPersister!

beforeEach {
persister = DiskAppPersister(pathInDocuments:"AppStoreSpec.json")
appStore = AppStore(persister: persister)
}
afterEach {
persister.destroy()
persister = nil
appStore = nil
}
context("Creation") {
it("has fresh state") {expect(appStore.state).to(equal(AppState())) }
}
context("Change Store") {
var subscriber: SubscriberMock!
var project : Project!
beforeEach {
subscriber = SubscriberMock()
project = Project(title:"Conquer the world!")
}
afterEach {
subscriber = nil
project = nil
}
it("has no projects in state") { expect(appStore.state.projects).to(beEmpty()) }

context("Adding Subscriber"){
beforeEach {
appStore.subscribe(subscriber:subscriber)
}
it("has not notified subscriber") { expect(subscriber.hasBeenNotified).to(beFalse()) }
context("Adding Project") {
beforeEach {
appStore.change(.adding(.project(project)))
}
it("has project added to state") { expect(appStore.state.projects ).to(equal([project])) }
it("notified subscriber" ) { expect(subscriber.hasBeenNotified).to(beTrue() ) }
}
}
}
}
}
}

class SubscriberMock: AppStoreSubscriber {
var hasBeenNotified = false
func updated(store: AppStore) {
hasBeenNotified = true
}
}

And the DiskAppPersister specifications:

final class DiskAppPersisterSpec: QuickSpec {
override class func spec() {
describe("DiskAppPersister") {
var diskAppPersiter: DiskAppPersister!
beforeEach {
diskAppPersiter = DiskAppPersister(pathInDocuments:"DiskAppPersister.Spec.json")
}
afterEach {
diskAppPersiter.destroy()
diskAppPersiter = nil
}
context("Load") {
var appState: AppState!
beforeEach {
appState = diskAppPersiter.loadAppState()
}
afterEach {
appState = nil
}
context("Empty AppState") {
it("loads empty state") { expect(appState).to(equal(AppState())) }
}
context("Persisted AppState") {
var loadedAppState: AppState!
var project: Project!
beforeEach {
project = Project(title:"World Domination!")
appState = AppState().alter(by: .adding(.project(project)))
diskAppPersiter.persist(appState: appState)
loadedAppState = diskAppPersiter.loadAppState()
}
afterEach {
loadedAppState = nil
project = nil
}
it("loads state with given project") { expect(loadedAppState).to(equal(AppState().alter(by: .adding(.project(project))))) }
}
}
}
}
}

We have seen how to write model types in Colibri, how to test them BDD-style, and how to store them, including persisting them on disk.
Now it is time to bundle them in an architecture (based on Robert C. Martin’s Clean Architecture) and interface it with an UI.
This will be the topic of the next article regarding Colibri. So stay tuned and follow me not to miss the publication of it in the next few days.

--

--

Manuel Meyer
Manuel Meyer

Written by Manuel Meyer

Freelance Software Developer and Code Strategist.

No responses yet