A small Swift package that loads a photo feed from the Unsplash API, built test-first.
![]() |
![]() |
| iOS, a scrolling feed | macOS, a grid gallery |
Swift Package Manager:
In Xcode, File ▸ Add Package Dependencies… and paste the URL:
https://github.com/A-bv/tdd-unsplash-feed
Register your app at unsplash.com/oauth/applications to get a free Access Key. Then create a loader and show the photos.
import SwiftUI
import UnsplashFeed
struct FeedView: View {
@State private var images: [UnsplashImage] = []
let loader = UnsplashFeed.makeRemoteFeedLoader(accessKey: "YOUR_ACCESS_KEY")
var body: some View {
List(images, id: \.id) { image in
AsyncImage(url: image.url) { $0.resizable().scaledToFill() } placeholder: { ProgressView() }
.frame(height: 200)
}
.task { images = (try? await loader.load()) ?? [] }
}
}Not using SwiftUI? The call is the same everywhere. Make a loader once, then:
let images = try await loader.load() // [UnsplashImage], each with a url and an authorNameEach of these is locked in by a test. In plain words:
- It waits until you ask. Just creating the loader never calls the internet. It fetches only when you call
load(). - A dead connection is clear. If the network fails, you get a simple connection error, not a raw system message.
- A bad answer is caught. A web server replies with a status code, and 200 means OK. If the answer is anything else, like 404 or 500, the loader stops and reports the data as invalid.
- A good answer becomes photos. On a 200 OK, the loader reads the returned data and turns it into a list of images.
TDD (Test-Driven Development) means building the code in tiny steps. Each step is the same three-part cycle.
- Red. Write one small test for a behavior you do not have yet. Run it. It fails, because the code is not there.
- Green. Write the least code that makes the test pass. Run it. It passes.
- Refactor. Clean up what you just wrote, while the tests stay green.
Then you repeat for the next behavior. Nothing is written unless a test asked for it.
Here is one cycle, for the behavior "a network failure should become a connectivity error".
Red, the failing test:
func test_load_deliversConnectivityErrorOnClientError() async {
let (sut, client) = makeSUT()
client.stub(error: anyNSError())
await assertThrows(RemoteFeedLoader.Error.connectivity) {
_ = try await sut.load()
}
}In that test, makeSUT builds the loader with a fake network client, stub(error:) tells the fake to fail, and assertThrows checks the loader turns the failure into a connectivity error.
Green, the code that makes it pass:
do {
_ = try await client.get(from: url)
} catch {
throw Error.connectivity
}Refactor happens later, once a few behaviors exist, for example moving the JSON parsing into its own type.
The whole feature was built this way. Read the commit history from the bottom up and the cycle repeats:
- 🔴 a test commit, the failing test plus any fake it needs to run, like a spy or a stub
- 🟢 a feat commit that adds only the production code that makes it pass
- 🔵 a refactor commit that tidies up
Run swift test. The 16 tests are fully offline. The network is faked with a stub, so no key or internet is needed.
MIT. See LICENSE.
Unofficial. Not affiliated with or endorsed by Unsplash.

