Swift
Testing
Run your integration with no network and no keychain, exercise every refusal on purpose, and check the things that only break on a real device.
Two seams
VoteFirstClient takes its network and its storage as arguments, so a test can replace both.
let client = try VoteFirstClient(
project: "demo-product",
key: "vf_pk_test",
host: URL(string: "https://example.invalid")!,
transport: myTransport,
tokenStore: InMemoryTokenStore()
)
Use the two value initialiser in a test. Naming the project skips the one request that would otherwise go and find the slug, which is a request your script would have to answer.
InMemoryTokenStore keeps the voter token in memory, so a test does not write to the keychain, does not inherit a token from the last run, and does not leave one behind.
The one thing that is not a seam is the outbox, which is always a file under the project name. Give each test its own project name if you are exercising the queue.
let client = try VoteFirstClient(project: "test-\(UUID().uuidString)", key: "vf_pk_test",
transport: myTransport, tokenStore: InMemoryTokenStore())
A transport that answers from a script
Transport is one method.
actor StubTransport: Transport {
private var script: [(status: Int, body: String)]
private(set) var requests: [URLRequest] = []
init(_ script: [(status: Int, body: String)]) {
self.script = script
}
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
requests.append(request)
guard !script.isEmpty else { throw URLError(.notConnectedToInternet) }
let step = script.removeFirst()
let response = HTTPURLResponse(url: request.url!, statusCode: step.status,
httpVersion: "HTTP/1.1", headerFields: nil)!
return (Data(step.body.utf8), response)
}
}
An actor rather than a class with a lock, because send is async and the requests it remembers are read from a test that is somewhere else. await transport.requests is then how a test asserts what was sent.
Every write mints a voter first, so the first step in a script is nearly always the identity.
let identity = """
{"data":{"voter_id":"anon_1","kind":"anonymous","anon_token":"tok_1",
"display_name":"BraveOtter_1","banned":false,"voted_feature_ids":[]},"meta":null,"error":null}
"""
let vote = """
{"data":{"feature_id":7,"has_voted":true,"vote_count":4,"score":4},"meta":null,"error":null}
"""
let transport = StubTransport([(201, identity), (200, vote)])
Throwing a URLError is how a test goes offline. Answering an error envelope with the status to match is how it exercises a refusal.
let refused = """
{"data":null,"meta":null,"error":{"code":"not_allowed","message":"this column does not accept votes","field":null}}
"""
Every response envelope carries all three of data, meta and error, and a stub should too. The package decodes one shape.
Testing your own screen
The façade holds one client, and it is replaced by calling start again, so a test that drives your own view can point the whole package somewhere harmless.
VoteFirst.start(project: "demo-product", key: "vf_pk_test",
host: URL(string: "https://example.invalid")!)
That still uses the real transport. For a screen test with no network at all, hold your own client and pass it into your own view model rather than reaching for the façade. A view model that takes a VoteFirstClient is testable; one that reaches for VoteFirst.client is not.
Against a real board
Point a test at your production board for reads only. A test that votes leaves votes, and a test that suggests leaves something in your moderation queue.
Anything that writes belongs on a board you do not mind writing to: a second project made for the purpose, or a local stack.
VoteFirst.start(project: "demo-product", key: "vf_pk_...",
host: URL(string: "http://localhost:18080")!)
A board answers the same way wherever it is running, including the budgets, the moderation queue and the 409s.
The sample app
The package ships one under Examples/VoteFirstSample: a small app showing every view, pointed at a host and a project you give it. It is the fastest way to see what a screen does before you decide where to put it.
Before you ship
- The key in the binary starts with
vf_pk_, and it is the right project's. - The host is the default, not a local stack left over from a test.
termsURLandprivacyURLare set if your app is on the App Store with content other people wrote in it.- App Store Connect declares User ID and Other User Content. See Privacy.
- The board has been opened once on a device in airplane mode, and a vote made there arrived after the network came back.
- A column closed to votes draws a disabled button rather than one that fails.
- Somebody the project has banned sees a sentence rather than a form.