BDD Testing in Swift with Quick and Nimble
Quick and Nimble are the most popular BDD testing libraries for Swift. Quick provides a describe/it DSL for organizing tests; Nimble provides expressive matchers. Together they make test intent clearer than raw XCTest and scale better for complex domain logic.
Installation
Swift Package Manager
// Package.swift or Xcode Package Dependencies
.package(url: "https://github.com/Quick/Quick.git", from: "7.0.0"),
.package(url: "https://github.com/Quick/Nimble.git", from: "13.0.0"),Add Quick and Nimble to your test target.
A Quick Spec
import Quick
import Nimble
final class CalculatorSpec: QuickSpec {
override class func spec() {
describe("Calculator") {
var calculator: Calculator!
beforeEach {
calculator = Calculator()
}
describe("add") {
it("returns the sum of two numbers") {
expect(calculator.add(2, 3)).to(equal(5))
}
it("handles negative numbers") {
expect(calculator.add(-1, 1)).to(equal(0))
}
}
describe("divide") {
it("returns the quotient") {
expect(try calculator.divide(10, by: 2)).to(equal(5))
}
it("throws on division by zero") {
expect { try calculator.divide(10, by: 0) }
.to(throwError(CalculatorError.divisionByZero))
}
}
}
}
}Tests are organized in describe blocks; individual cases use it. beforeEach runs before each it within its containing describe.
Nimble Matchers
Nimble's matcher library is more expressive than XCTest:
Equality and Comparison
expect(result).to(equal(42))
expect(result).notTo(equal(0))
expect(result).to(beGreaterThan(10))
expect(result).to(beLessThanOrEqualTo(100))
expect(result).to(beCloseTo(3.14, within: 0.01)) // floating pointNil and Optional
expect(value).to(beNil())
expect(value).notTo(beNil())Boolean
expect(condition).to(beTrue())
expect(condition).to(beFalse())Strings
expect(string).to(contain("hello"))
expect(string).to(beginWith("Hello"))
expect(string).to(endWith("world"))
expect(string).to(match("^[A-Z][a-z]+$")) // regexCollections
expect(array).to(haveCount(3))
expect(array).to(contain("element"))
expect(array).to(containElementSatisfying { $0.age > 18 })
expect(array).to(beEmpty())
expect(dict).to(haveKey("name"))Type Checking
expect(value).to(beAKindOf(UIViewController.self))
expect(value).to(beAnInstanceOf(LoginViewController.self))Errors
expect { try riskyOperation() }.to(throwError())
expect { try riskyOperation() }.to(throwError(MyError.specific))
expect { try safeOperation() }.notTo(throwError())Organizing Specs with context
context is an alias for describe — use it to describe preconditions:
describe("UserService") {
var service: UserService!
var mockRepository: MockUserRepository!
beforeEach {
mockRepository = MockUserRepository()
service = UserService(repository: mockRepository)
}
describe("createUser") {
context("when repository succeeds") {
beforeEach {
mockRepository.stubbedCreateResult = User(id: 1, name: "Alice")
}
it("returns the created user") {
let user = try service.createUser(name: "Alice")
expect(user.name).to(equal("Alice"))
}
it("calls repository once") {
_ = try service.createUser(name: "Alice")
expect(mockRepository.createCallCount).to(equal(1))
}
}
context("when repository throws") {
beforeEach {
mockRepository.stubbedError = RepositoryError.connectionFailed
}
it("propagates the error") {
expect { try service.createUser(name: "Alice") }
.to(throwError(RepositoryError.connectionFailed))
}
}
}
}describe/context/it nesting maps directly to test report output — failures show the full nesting path.
Async Specs
Quick supports async/await specs:
final class AsyncServiceSpec: AsyncSpec {
override class func spec() {
describe("AsyncService") {
it("fetches data asynchronously") {
let service = AsyncService()
let result = try await service.fetchData()
expect(result).notTo(beEmpty())
}
it("throws on network error") {
let service = AsyncService(url: "invalid://url")
await expect { try await service.fetchData() }
.to(throwError())
}
}
}
}Use AsyncSpec instead of QuickSpec for async test specs.
beforeSuite and afterSuite
One-time setup across all specs:
final class DatabaseSetupSpec: QuickSpec {
override class func spec() {
beforeSuite {
Database.shared.openInMemory()
}
afterSuite {
Database.shared.close()
}
// tests...
}
}Custom Matchers
Build domain-specific matchers for clearer failure messages:
import Nimble
func beValidEmail() -> Matcher<String> {
return Matcher { expression in
let message = ExpectationMessage.expectedTo("be a valid email address")
guard let value = try expression.evaluate() else {
return MatcherResult(status: .fail, message: message.appendedBeNilHint())
}
let isValid = value.contains("@") && value.contains(".")
return MatcherResult(bool: isValid, message: message)
}
}
// Usage
expect("user@example.com").to(beValidEmail())
expect("notanemail").notTo(beValidEmail())Custom matchers produce readable failure messages:
expected to be a valid email address, got "notanemail"Shared Examples
Reuse behavior specs across multiple types:
sharedExamples("a valid serializable object") { (sharedExampleContext: @escaping SharedExampleContext) in
var subject: Serializable!
beforeEach {
subject = sharedExampleContext()["subject"] as? Serializable
}
it("serializes without throwing") {
expect { try subject.serialize() }.notTo(throwError())
}
it("deserializes to equal value") {
let data = try! subject.serialize()
let restored = try! type(of: subject).deserialize(data)
expect(restored).to(equal(subject))
}
}
final class UserSpec: QuickSpec {
override class func spec() {
describe("User") {
itBehavesLike("a valid serializable object") {
["subject": User(name: "Alice")]
}
}
}
}Quick vs XCTest
| Feature | XCTest | Quick/Nimble |
|---|---|---|
| Organization | class + func test* |
describe/context/it |
| Setup | setUp/tearDown |
beforeEach/afterEach |
| Matchers | XCTAssertEqual |
expect().to(equal()) |
| BDD structure | No | Yes |
| Xcode integration | Native | Plugin-based |
| Async support | Native | AsyncSpec |
Use XCTest for simple unit tests. Add Quick/Nimble when specs are complex enough to benefit from context/describe nesting and richer matchers.
CI Integration
Quick tests run under xcodebuild test exactly like XCTest — no special flags needed:
xcodebuild test \
-scheme MyApp \
-destination <span class="hljs-string">'platform=iOS Simulator,name=iPhone 16' \
-only-testing:MyAppTestsProduction Monitoring
Quick/Nimble tests validate behavior against in-process test doubles. For production monitoring of iOS backend APIs and services, HelpMeTest runs continuous tests against live endpoints — no Xcode, no simulators required.
Summary
- Quick provides
describe/context/itBDD structure; Nimble provides expressive matchers beforeEach/afterEachscoped to their containing block — no global state contaminationcontextis an alias fordescribe— use it to describe preconditions- Async support via
AsyncSpec—async/awaitinitblocks - Custom matchers with meaningful failure messages improve test readability
- Shared examples reuse behavior specs across multiple types