Skip to content

Commit

Permalink
Switch to Swift Testing
Browse files Browse the repository at this point in the history
We’re doing this because it’s, I guess, the future, and offers
performance improvements, but more immediately because it reduces
verbosity when testing `async` functions and properties.

I’ve done the migration in a pretty straightforward fashion, just
stripping the `test` prefix from the method names (which my eyes are
still getting used to), and not trying to use any new features like test
descriptions, nested suites, or tags. We can figure out how we want to
use these as we get used to using Swift Testing.

Decided to keep testing for errors via a do { … } catch { … } instead of
using #expect(…, throws:) because I like being able to write tests in a
Given / When / Then fashion — i.e. do things, then make assertions.

Whilst working on this, I noticed that some of Swift Testing’s useful
test failure messages stop being so useful when testing asynchronous
code, which takes a bit of the shine off it. For example, if you write

> #expect(status.current == .attached)

then you’ll get a failure message of

> Expectation failed: (current → .detached) == .attached

that is, it shows you information about `current` which helps you to
understand why the expectation failed.

But if, on the other hand, you write

> #expect(await status.current == .attached)

then you’ll get a failure message of

> Expectation failed: await status.current == .detached

which is less useful. I asked about this in [1] and was told that it’s a
known issue and that Swift macro limitations mean it’s unlikely to be
fixed soon. ([2] is a similar question that I found after.) If we decide
that this is a dealbreaker and that we want the rich failure messages,
then we’ll need to switch back to the current way of doing things; that
is, first do the `await` and then the #expect.

Resolves #55.

[1] https://forums.swift.org/t/expectation-failure-messages-are-less-useful-with-await/74754
[2] https://forums.swift.org/t/try-expect-throwing-or-expect-try-throwing/73076/17
  • Loading branch information
lawrence-forooghian committed Sep 23, 2024
1 parent 01c95a0 commit f5cab27
Show file tree
Hide file tree
Showing 9 changed files with 107 additions and 114 deletions.
18 changes: 10 additions & 8 deletions Tests/AblyChatTests/DefaultChatClientTests.swift
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
@testable import AblyChat
import XCTest
import Testing

class DefaultChatClientTests: XCTestCase {
func test_init_withoutClientOptions() {
struct DefaultChatClientTests {
@Test
func init_withoutClientOptions() {
// Given: An instance of DefaultChatClient is created with nil clientOptions
let client = DefaultChatClient(realtime: MockRealtime.create(), clientOptions: nil)

// Then: It uses the default client options
let defaultOptions = ClientOptions()
XCTAssertTrue(client.clientOptions.isEqualForTestPurposes(defaultOptions))
#expect(client.clientOptions.isEqualForTestPurposes(defaultOptions))
}

func test_rooms() throws {
@Test
func rooms() throws {
// Given: An instance of DefaultChatClient
let realtime = MockRealtime.create()
let options = ClientOptions()
Expand All @@ -20,8 +22,8 @@ class DefaultChatClientTests: XCTestCase {
// Then: Its `rooms` property returns an instance of DefaultRooms with the same realtime client and client options
let rooms = client.rooms

let defaultRooms = try XCTUnwrap(rooms as? DefaultRooms)
XCTAssertIdentical(defaultRooms.realtime, realtime)
XCTAssertTrue(defaultRooms.clientOptions.isEqualForTestPurposes(options))
let defaultRooms = try #require(rooms as? DefaultRooms)
#expect(defaultRooms.realtime === realtime)
#expect(defaultRooms.clientOptions.isEqualForTestPurposes(options))
}
}
27 changes: 15 additions & 12 deletions Tests/AblyChatTests/DefaultInternalLoggerTests.swift
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
@testable import AblyChat
import XCTest
import Testing

class DefaultInternalLoggerTests: XCTestCase {
func test_defaults() {
struct DefaultInternalLoggerTests {
@Test
func defaults() {
let logger = DefaultInternalLogger(logHandler: nil, logLevel: nil)

XCTAssertTrue(logger.logHandler is DefaultLogHandler)
XCTAssertEqual(logger.logLevel, .error)
#expect(logger.logHandler is DefaultLogHandler)
#expect(logger.logLevel == .error)
}

func test_log() throws {
@Test
func log() throws {
// Given: A DefaultInternalLogger instance
let logHandler = MockLogHandler()
let logger = DefaultInternalLogger(logHandler: logHandler, logLevel: nil)
Expand All @@ -22,13 +24,14 @@ class DefaultInternalLoggerTests: XCTestCase {
)

// Then: It calls log(…) on the underlying logger, interpolating the code location into the message and passing through the level
let logArguments = try XCTUnwrap(logHandler.logArguments)
XCTAssertEqual(logArguments.message, "(Ably/Room.swift:123) Hello")
XCTAssertEqual(logArguments.level, .error)
XCTAssertNil(logArguments.context)
let logArguments = try #require(logHandler.logArguments)
#expect(logArguments.message == "(Ably/Room.swift:123) Hello")
#expect(logArguments.level == .error)
#expect(logArguments.context == nil)
}

func test_log_whenLogLevelArgumentIsLessSevereThanLogLevelProperty_itDoesNotLog() {
@Test
func log_whenLogLevelArgumentIsLessSevereThanLogLevelProperty_itDoesNotLog() {
// Given: A DefaultInternalLogger instance
let logHandler = MockLogHandler()
let logger = DefaultInternalLogger(
Expand All @@ -44,6 +47,6 @@ class DefaultInternalLoggerTests: XCTestCase {
)

// Then: It does not call `log(…)` on the underlying logger
XCTAssertNil(logHandler.logArguments)
#expect(logHandler.logArguments == nil)
}
}
33 changes: 14 additions & 19 deletions Tests/AblyChatTests/DefaultRoomStatusTests.swift
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
@testable import AblyChat
import XCTest
import Testing

class DefaultRoomStatusTests: XCTestCase {
func test_current_startsAsInitialized() async {
struct DefaultRoomStatusTests {
@Test
func current_startsAsInitialized() async {
let status = DefaultRoomStatus(logger: TestLogger())
let current = await status.current
XCTAssertEqual(current, .initialized)
#expect(await status.current == .initialized)
}

func test_error_startsAsNil() async {
@Test()
func error_startsAsNil() async {
let status = DefaultRoomStatus(logger: TestLogger())
let error = await status.error
XCTAssertNil(error)
#expect(await status.error == nil)
}

func test_transition() async {
@Test
func transition() async throws {
// Given: A RoomStatus
let status = DefaultRoomStatus(logger: TestLogger())
let originalState = await status.current
Expand All @@ -30,17 +31,11 @@ class DefaultRoomStatusTests: XCTestCase {
await status.transition(to: newState)

// Then: It emits a status change to all subscribers added via onChange(bufferingPolicy:), and updates its `current` property to the new state
guard let statusChange1 = await statusChange1, let statusChange2 = await statusChange2 else {
XCTFail("Expected status changes to be emitted")
return
for statusChange in try await [#require(statusChange1), #require(statusChange2)] {
#expect(statusChange.previous == originalState)
#expect(statusChange.current == newState)
}

for statusChange in [statusChange1, statusChange2] {
XCTAssertEqual(statusChange.previous, originalState)
XCTAssertEqual(statusChange.current, newState)
}

let current = await status.current
XCTAssertEqual(current, .attached)
#expect(await status.current == .attached)
}
}
44 changes: 18 additions & 26 deletions Tests/AblyChatTests/DefaultRoomTests.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import Ably
@testable import AblyChat
import XCTest
import Testing

class DefaultRoomTests: XCTestCase {
func test_attach_attachesAllChannels_andSucceedsIfAllSucceed() async throws {
struct DefaultRoomTests {
@Test
func attach_attachesAllChannels_andSucceedsIfAllSucceed() async throws {
// Given: a DefaultRoom instance with ID "basketball", with a Realtime client for which `attach(_:)` completes successfully if called on the following channels:
//
// - basketball::$chat::$chatMessages
Expand All @@ -26,19 +27,15 @@ class DefaultRoomTests: XCTestCase {

// Then: `attach(_:)` is called on each of the channels, the room `attach` call succeeds, and the room transitions to ATTACHED
for channel in channelsList {
XCTAssertTrue(channel.attachCallCounter.isNonZero)
#expect(channel.attachCallCounter.isNonZero)
}

guard let attachedStatusChange = await attachedStatusChange else {
XCTFail("Expected status change to ATTACHED but didn't get one")
return
}
let currentStatus = await room.status.current
XCTAssertEqual(currentStatus, .attached)
XCTAssertEqual(attachedStatusChange.current, .attached)
#expect(await room.status.current == .attached)
#expect(try #require(await attachedStatusChange).current == .attached)
}

func test_attach_attachesAllChannels_andFailsIfOneFails() async throws {
@Test
func attach_attachesAllChannels_andFailsIfOneFails() async throws {
// Given: a DefaultRoom instance, with a Realtime client for which `attach(_:)` completes successfully if called on the following channels:
//
// - basketball::$chat::$chatMessages
Expand All @@ -65,11 +62,11 @@ class DefaultRoomTests: XCTestCase {
}

// Then: the room `attach` call fails with the same error as the channel `attach(_:)` call
let roomAttachErrorInfo = try XCTUnwrap(roomAttachError as? ARTErrorInfo)
XCTAssertIdentical(roomAttachErrorInfo, channelAttachError)
#expect(try #require(roomAttachError as? ARTErrorInfo) === channelAttachError)
}

func test_detach_detachesAllChannels_andSucceedsIfAllSucceed() async throws {
@Test
func detach_detachesAllChannels_andSucceedsIfAllSucceed() async throws {
// Given: a DefaultRoom instance with ID "basketball", with a Realtime client for which `detach(_:)` completes successfully if called on the following channels:
//
// - basketball::$chat::$chatMessages
Expand All @@ -92,19 +89,15 @@ class DefaultRoomTests: XCTestCase {

// Then: `detach(_:)` is called on each of the channels, the room `detach` call succeeds, and the room transitions to DETACHED
for channel in channelsList {
XCTAssertTrue(channel.detachCallCounter.isNonZero)
#expect(channel.detachCallCounter.isNonZero)
}

guard let detachedStatusChange = await detachedStatusChange else {
XCTFail("Expected status change to DETACHED but didn't get one")
return
}
let currentStatus = await room.status.current
XCTAssertEqual(currentStatus, .detached)
XCTAssertEqual(detachedStatusChange.current, .detached)
#expect(await room.status.current == .detached)
#expect(try #require(await detachedStatusChange).current == .detached)
}

func test_detach_detachesAllChannels_andFailsIfOneFails() async throws {
@Test
func detach_detachesAllChannels_andFailsIfOneFails() async throws {
// Given: a DefaultRoom instance, with a Realtime client for which `detach(_:)` completes successfully if called on the following channels:
//
// - basketball::$chat::$chatMessages
Expand All @@ -131,7 +124,6 @@ class DefaultRoomTests: XCTestCase {
}

// Then: the room `detach` call fails with the same error as the channel `detach(_:)` call
let roomDetachErrorInfo = try XCTUnwrap(roomDetachError as? ARTErrorInfo)
XCTAssertIdentical(roomDetachErrorInfo, channelDetachError)
#expect(try #require(roomDetachError as? ARTErrorInfo) === channelDetachError)
}
}
25 changes: 14 additions & 11 deletions Tests/AblyChatTests/DefaultRoomsTests.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
@testable import AblyChat
import XCTest
import Testing

class DefaultRoomsTests: XCTestCase {
struct DefaultRoomsTests {
// @spec CHA-RC1a
func test_get_returnsRoomWithGivenID() async throws {
@Test
func get_returnsRoomWithGivenID() async throws {
// Given: an instance of DefaultRooms
let realtime = MockRealtime.create()
let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger())
Expand All @@ -14,14 +15,15 @@ class DefaultRoomsTests: XCTestCase {
let room = try await rooms.get(roomID: roomID, options: options)

// Then: It returns a DefaultRoom instance that uses the same Realtime instance, with the given ID and options
let defaultRoom = try XCTUnwrap(room as? DefaultRoom)
XCTAssertIdentical(defaultRoom.realtime, realtime)
XCTAssertEqual(defaultRoom.roomID, roomID)
XCTAssertEqual(defaultRoom.options, options)
let defaultRoom = try #require(room as? DefaultRoom)
#expect(defaultRoom.realtime === realtime)
#expect(defaultRoom.roomID == roomID)
#expect(defaultRoom.options == options)
}

// @spec CHA-RC1b
func test_get_returnsExistingRoomWithGivenID() async throws {
@Test
func get_returnsExistingRoomWithGivenID() async throws {
// Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID
let realtime = MockRealtime.create()
let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger())
Expand All @@ -34,11 +36,12 @@ class DefaultRoomsTests: XCTestCase {
let secondRoom = try await rooms.get(roomID: roomID, options: options)

// Then: It returns the same room object
XCTAssertIdentical(secondRoom, firstRoom)
#expect(secondRoom === firstRoom)
}

// @spec CHA-RC1c
func test_get_throwsErrorWhenOptionsDoNotMatch() async throws {
@Test
func get_throwsErrorWhenOptionsDoNotMatch() async throws {
// Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID and options
let realtime = MockRealtime.create()
let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger())
Expand All @@ -59,6 +62,6 @@ class DefaultRoomsTests: XCTestCase {
}

// Then: It throws an inconsistentRoomOptions error
try assertIsChatError(caughtError, withCode: .inconsistentRoomOptions)
#expect(isChatError(caughtError, withCode: .inconsistentRoomOptions))
}
}
16 changes: 8 additions & 8 deletions Tests/AblyChatTests/Helpers/Helpers.swift
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import Ably
@testable import AblyChat
import XCTest

/**
Asserts that a given optional `Error` is an `ARTErrorInfo` in the chat error domain with a given code.
Tests whether a given optional `Error` is an `ARTErrorInfo` in the chat error domain with a given code.
*/
func assertIsChatError(_ maybeError: (any Error)?, withCode code: AblyChat.ErrorCode, file: StaticString = #filePath, line: UInt = #line) throws {
let error = try XCTUnwrap(maybeError, "Expected an error", file: file, line: line)
let ablyError = try XCTUnwrap(error as? ARTErrorInfo, "Expected an ARTErrorInfo", file: file, line: line)
func isChatError(_ maybeError: (any Error)?, withCode code: AblyChat.ErrorCode) -> Bool {
guard let ablyError = maybeError as? ARTErrorInfo else {
return false
}

XCTAssertEqual(ablyError.domain, AblyChat.errorDomain as String, file: file, line: line)
XCTAssertEqual(ablyError.code, code.rawValue, file: file, line: line)
XCTAssertEqual(ablyError.statusCode, code.statusCode, file: file, line: line)
return ablyError.domain == AblyChat.errorDomain as String
&& ablyError.code == code.rawValue
&& ablyError.statusCode == code.statusCode
}
15 changes: 8 additions & 7 deletions Tests/AblyChatTests/InternalLoggerTests.swift
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
@testable import AblyChat
import XCTest
import Testing

class InternalLoggerTests: XCTestCase {
func test_protocolExtension_logMessage_defaultArguments_populatesFileIDAndLine() throws {
struct InternalLoggerTests {
@Test
func protocolExtension_logMessage_defaultArguments_populatesFileIDAndLine() throws {
let logger = MockInternalLogger()

let expectedLine = #line + 1
logger.log(message: "Here is a message", level: .info)

let receivedArguments = try XCTUnwrap(logger.logArguments)
let receivedArguments = try #require(logger.logArguments)

XCTAssertEqual(receivedArguments.level, .info)
XCTAssertEqual(receivedArguments.message, "Here is a message")
XCTAssertEqual(receivedArguments.codeLocation, .init(fileID: #fileID, line: expectedLine))
#expect(receivedArguments.level == .info)
#expect(receivedArguments.message == "Here is a message")
#expect(receivedArguments.codeLocation == .init(fileID: #fileID, line: expectedLine))
}
}
25 changes: 12 additions & 13 deletions Tests/AblyChatTests/MessageSubscriptionTests.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@testable import AblyChat
import AsyncAlgorithms
import XCTest
import Testing

private final class MockPaginatedResult<T>: PaginatedResult {
var items: [T] { fatalError("Not implemented") }
Expand All @@ -18,39 +18,38 @@ private final class MockPaginatedResult<T>: PaginatedResult {
init() {}
}

class MessageSubscriptionTests: XCTestCase {
struct MessageSubscriptionTests {
let messages = ["First", "Second"].map { text in
Message(timeserial: "", clientID: "", roomID: "", text: text, createdAt: .init(), metadata: [:], headers: [:])
}

func testWithMockAsyncSequence() async {
@Test
func withMockAsyncSequence() async {
let subscription = MessageSubscription(mockAsyncSequence: messages.async) { _ in fatalError("Not implemented") }

async let emittedElements = Array(subscription.prefix(2))

let awaitedEmittedElements = await emittedElements
XCTAssertEqual(awaitedEmittedElements.map(\.text), ["First", "Second"])
#expect(await Array(subscription.prefix(2)).map(\.text) == ["First", "Second"])
}

func testEmit() async {
@Test
func emit() async {
let subscription = MessageSubscription(bufferingPolicy: .unbounded)

async let emittedElements = Array(subscription.prefix(2))

subscription.emit(messages[0])
subscription.emit(messages[1])

let awaitedEmittedElements = await emittedElements
XCTAssertEqual(awaitedEmittedElements.map(\.text), ["First", "Second"])
#expect(await emittedElements.map(\.text) == ["First", "Second"])
}

func testMockGetPreviousMessages() async throws {
@Test
func mockGetPreviousMessages() async throws {
let mockPaginatedResult = MockPaginatedResult<Message>()
let subscription = MessageSubscription(mockAsyncSequence: [].async) { _ in mockPaginatedResult }

let result = try await subscription.getPreviousMessages(params: .init())
// This dance is to avoid the compiler error "Runtime support for parameterized protocol types is only available in iOS 16.0.0 or newer" — casting back to a concrete type seems to avoid this
let resultAsConcreteType = try XCTUnwrap(result as? MockPaginatedResult<Message>)
XCTAssertIdentical(resultAsConcreteType, mockPaginatedResult)
let resultAsConcreteType = try #require(result as? MockPaginatedResult<Message>)
#expect(resultAsConcreteType === mockPaginatedResult)
}
}
Loading

0 comments on commit f5cab27

Please sign in to comment.