Skip to content
VoteFirst Docs
Dashboard

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.

In a test
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.

In a test
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.

StubTransport.swift
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.

In a test
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.

In a test
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.

In a test
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.

In a test
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.
  • termsURL and privacyURL are 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.

Next