Skip to content

Commit

Permalink
Notes on testing concurrent code in swift
Browse files Browse the repository at this point in the history
  • Loading branch information
younata committed Aug 12, 2023
1 parent f667579 commit 543a2cc
Show file tree
Hide file tree
Showing 4 changed files with 157 additions and 0 deletions.
16 changes: 16 additions & 0 deletions .spelling
Expand Up @@ -532,6 +532,22 @@ Johanson's
McMurdo
Johanson
Wendover
1930s
recumbents
velomobile
velomobiles
Velotechnik
under-seat-stearing
frankenrunner
phaserunner
ERider
155mm
sendable
runtime
maintainership
todo
midori
toEventually
- src/astronomy/astrophotography/index.md
Cafuego's
astrodslr
Expand Down
3 changes: 3 additions & 0 deletions src/programming/swift/concurrency/README.md
@@ -0,0 +1,3 @@
# Concurrency

Async/Await, Tasks, Actors and Sendable in Swift.
130 changes: 130 additions & 0 deletions src/programming/swift/concurrency/testing.md
@@ -0,0 +1,130 @@
# Testing Concurrent Code

There's enough differences between testing code using Swift Concurrency (async/await) and testing more traditional, callback-based asynchronous code. This page intends to document some of what I've learned.

## Make Fakes Threadsafe

Because Swift Concurrency will run your code concurrently, you are at much higher risk of running into concurrent access problems. Especially if you use something like Nimble's `toEventually` behavior. Which you almost have to use if you want to be able to test the in progress state of concurrent code.

I've found that, where possible, making fakes be Actors is the best way to handle this. Actors implicitly conform to Sendable, and they handle concurrent access by only allowing one thread of work to access it at a time. However, you can also make fakes threadsafe by using tools like locks. If you don't do this, then you'll run into annoying common and hard to diagnose test crashes.

## Pre-resolve Concurrent Fakes

One of the assumptions in Swift Concurrency is that threads will never be blocked. Which was a problem because my preferred approach for testing asynchronous code essentially blocks the running thread of work until the test resolves it (and sometimes the test only ever tests the in-progress state, so doesn't need to resolve). This end up resulting in incredibly high test flakiness until I realized it's better to pre-resolve concurrent functions - or, basically, treat them like they're synchronous functions.

For example, consider this given bit of code:

```swift
protocol SomeDependencyProtocol {
func method(_ callback: @escaping (Int) -> Void)
}

struct SubjectUnderTest {
let dependency: SomeDependencyProtocol

func perform(_ callback: @escaping (String) -> Void) {
dependency.method {
callback(String(describing: $0))
}
}
}

// Sample Fake Implementation.
// For brevity, ignoring thread-safety concerns.
final class FakeSomeDependencyProtocol {
private(set) var methodCalls = [(Int) -> Void]()
func method(_ callback: @escaping (Int) -> Void) {
methodCalls.append(callback)
}
}
```

Traditionally, I would test it almost in an Act Arrange Assert matter.

```swift
var subject: SubjectUnderTest!
var dependency: FakeSomeDependencyProtocol!

describe("perform(_:)") {
var result: String? = nil
beforeEach {
result = nil

subject.perform {
result = $0
}
}

// ...

describe("when the dependency returns") {
beforeEach {
dependency.methodCalls.last?(1)
}

it("calls the callback with the stringified value") {
expect(result).to(equal("1"))
}
}
}
```

Instead, I learned that it's significantly more reliable to pre-resolve the async dependencies, like so, like so:

```swift
protocol SomeDependencyProtocol {
func method() async -> Int
}

struct SubjectUnderTest {
let dependency: SomeDependencyProtocol

func perform() async -> String {
String(describing: await dependency.method())
}
}

// Sample Fake Implementation.
actor FakeSomeDependencyProtocol {
var methodStub = 0
func setMethodStub(_ stub: Int) {
methodStub = stub
}
func method() async -> Int {
methodStub
}
}
```

With a test, using `justBeforeEach` to allow the same structure as before:

```swift
var subject: SubjectUnderTest!
var dependency: FakeSomeDependencyProtocol!

describe("perform(_:)") {
var task: Task<String, Never>?
justBeforeEach {
task = Task { [subject] in await subject!.perform() }
}

afterEach {
task?.cancel()
task = nil
}

// ...

describe("when the dependency returns") {
beforeEach {
await dependency.setMethodStub(1)
}

it("returns the stringified value") {
await expect { await task!.value }.to(equal("1"))
}
}
}
```

For the unfamiliar, in Quick, using `justBeforeEach` means the passed-in closure will run after the other `beforeEach` closures in a test. So, in this example, the `await dependency.setMethodStub(1)` line will run before the `task = Task { ... }` line.
8 changes: 8 additions & 0 deletions src/todont.md
@@ -0,0 +1,8 @@
# To Don't

The opposite of a ToDo list.

This is mostly lists of projects I am interested in starting, but I know I will be a complete waste of time if I ever start it. These definitely could work with a team of people, but for a solo project, these aren't worth it.

- Web Browser. Something maybe like [Midori](https://en.wikipedia.org/wiki/Midori_(web_browser)) was a number of years ago (I last used it ~2010). Frankly, outside of basic stuff you can build on top of `WKWebView`, browsers aren't worth it. There's an (unfortunate) reason that there are really only 3 browsers out there.
- Email client. I actually built one ages ago when I was still in high school. Learned how to use POP and SMTP from that. But with more modern requirements, email clients aren't worth my time.

0 comments on commit 543a2cc

Please sign in to comment.