The balanced architecture of the mobile application prolongs the life of the project and developers.
In the last episode
Part 1 - Basic Architecture Components and How Composable Architecture Works
Testable code
In the previous release, a shopping list application framework was developed using Composable Architecture . Before continuing to increase the functionality, you need to save - cover the code with tests. In this article, we will consider two types of tests: unit tests for the system and snapshot tests for UI.
What we have?
Let's take another look at the current solution:
- the state of the screen is described by the list of products;
- two types of events: change a product by index and add a new one;
- the mechanism that processes actions and changes the state of the system is a bright contender for writing tests.
struct ShoppingListState: Equatable {
var products: [Product] = []
}
enum ShoppingListAction {
case productAction(Int, ProductAction)
case addProduct
}
let shoppingListReducer: Reducer<ShoppingListState, ShoppingListAction, ShoppingListEnviroment> = .combine(
productReducer.forEach(
state: \.products,
action: /ShoppingListAction.productAction,
environment: { _ in ProductEnviroment() }
),
Reducer { state, action, env in
switch action {
case .addProduct:
state.products.insert(
Product(id: UUID(), name: "", isInBox: false),
at: 0
)
return .none
case .productAction:
return .none
}
}
)
Test types
How to understand that architecture is not very good? Easy if you cannot cover it 100% with tests (Vladislav Zhukov)
Not all architectural patterns clearly define testing approaches. Let's see how Composable Arhitecutre solves this problem.
Unit tests
Composable Arhitecutre unit .
โ recuder' โ : send(Action) receive(Action). , .
Send(Action) .
Receive(Action) , โ action.
.do {} .
.
func testAddProduct() {
//
let store = TestStore(
initialState: ShoppingListState(
products: []
),
reducer: shoppingListReducer,
environment: ShoppingListEnviroment()
)
//
store.assert(
//
.send(.addProduct) { state in
//
state.products = [
Product(
id: UUID(),
name: "",
isInBox: false
)
]
}
)
}
, .
:
, , .
Reducer โ
?
ยซยป โ , .
, UUID . , "".
UUID . Composable Architecture (Environment).
ShoppingListEnviroment () UUID.
struct ShoppingListEnviroment {
var uuidGenerator: () -> UUID
}
:
Reducer { state, action, env in
switch action {
case .addProduct:
state.products.insert(
Product(
id: env.uuidGenerator(),
name: "",
isInBox: false
),
at: 0
)
return .none
...
}
}
, . :
func testAddProduct() {
let store = TestStore(
initialState: ShoppingListState(),
reducer: shoppingListReducer,
//
environment: ShoppingListEnviroment(
// UUID
uuidGenerator: { UUID(uuidString: "00000000-0000-0000-0000-000000000000")! }
)
)
store.assert(
// " "
.send(.addProduct) { newState in
//
newState.products = [
Product(
// UUID
id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!,
name: "",
isInBox: false
)
]
}
)
}
, . : saveProducts loadProducts:
struct ShoppingListEnviroment {
var uuidGenerator: () -> UUID
var save: ([Product]) -> Effect<Never, Never>
var load: () -> Effect<[Product], Never>
}
, , Effect. Effect โ Publisher. .
:
func testAddProduct() {
// , ,
var savedProducts: [Product] = []
// ,
var numberOfSaves = 0
//
let store = TestStore(
initialState: ShoppingListState(products: []),
reducer: shoppingListReducer,
environment: ShoppingListEnviroment(
uuidGenerator: { .mock },
//
//
saveProducts: { products in Effect.fireAndForget { savedProducts = products; numberOfSaves += 1 } },
//
//
loadProducts: { Effect(value: [Product(id: .mock, name: "Milk", isInBox: false)]) }
)
)
store.assert(
// load view
.send(.loadProducts),
// load
// productsLoaded([Product])
.receive(.productsLoaded([Product(id: .mock, name: "Milk", isInBox: false)])) {
$0.products = [
Product(id: .mock, name: "Milk", isInBox: false)
]
},
//
.send(.addProduct) {
$0.products = [
Product(id: .mock, name: "", isInBox: false),
Product(id: .mock, name: "Milk", isInBox: false)
]
},
// ,
.receive(.saveProducts),
//
.do {
XCTAssertEqual(savedProducts, [
Product(id: .mock, name: "", isInBox: false),
Product(id: .mock, name: "Milk", isInBox: false)
])
},
//
.send(.productAction(0, .updateName("Banana"))) {
$0.products = [
Product(id: .mock, name: "Banana", isInBox: false),
Product(id: .mock, name: "Milk", isInBox: false)
]
},
// endEditing textFiled'a
.send(.saveProducts),
//
.do {
XCTAssertEqual(savedProducts, [
Product(id: .mock, name: "Banana", isInBox: false),
Product(id: .mock, name: "Milk", isInBox: false)
])
}
)
// , 2
XCTAssertEqual(numberOfSaves, 2)
}
:
- unit ;
- ;
- , .
Unit-Snapshot UI
snapshot , Composable Arhitecture SnapshotTesting ( ).
, :
- ;
- ;
- ;
- .
Composable Architecture data-driven development, snapshot- โ UI .
:
import XCTest
import ComposableArchitecture
//
import SnapshotTesting
@testable import Composable
class ShoppingListSnapshotTests: XCTestCase {
func testEmptyList() {
// view
let listView = ShoppingListView(
//
store: ShoppingListStore(
//
initialState: ShoppingListState(products: []),
reducer: Reducer { _, _, _ in .none },
environment: ShoppingListEnviroment.mock
)
)
assertSnapshot(matching: listView, as: .image)
}
func testNewItem() {
let listView = ShoppingListView(
// store
// Store.mock(state:State)
store: .mock(state: ShoppingListState(
products: [Product(id: .mock, name: "", isInBox: false)]
))
)
assertSnapshot(matching: listView, as: .image)
}
func testSingleItem() {
let listView = ShoppingListView(
store: .mock(state: ShoppingListState(
products: [Product(id: .mock, name: "Milk", isInBox: false)]
))
)
assertSnapshot(matching: listView, as: .image)
}
func testCompleteItem() {
let listView = ShoppingListView(
store: .mock(state: ShoppingListState(
products: [Product(id: .mock, name: "Milk", isInBox: true)]
))
)
assertSnapshot(matching: listView, as: .image)
}
}
:
.
Debug mode โ
debug:
Reducer { state, action, env in
switch action { ... }
}.debug()
//
Reducer { state, action, env in
switch action { ... }
}.debugActions()
debug , :
received action:
ShoppingListAction.load
(No state changes)
received action:
ShoppingListAction.setupProducts(
[
Product(
id: 9F047826-B431-4D20-9B80-CC65D6A1101B,
name: "",
isInBox: false
),
Product(
id: D9834386-75BC-4B9C-B87B-121FFFDB2F93,
name: "Tesggggg",
isInBox: false
),
Product(
id: D4405C13-2BB9-4CD4-A3A2-8289EAC6678C,
name: "",
isInBox: false
),
]
)
โ ShoppingListState(
โ products: [
+ Product(
+ id: 9F047826-B431-4D20-9B80-CC65D6A1101B,
+ name: "",
+ isInBox: false
+ ),
+ Product(
+ id: D9834386-75BC-4B9C-B87B-121FFFDB2F93,
+ name: "Tesggggg",
+ isInBox: false
+ ),
+ Product(
+ id: D4405C13-2BB9-4CD4-A3A2-8289EAC6678C,
+ name: "",
+ isInBox: false
+ ),
โ ]
โ )
* .
3 โ , (in progress)
4 โ (in progress)
2: github.com
Composable Architecture: https://github.com/pointfreeco/swift-composable-architecture
Sources Snaphsot testing: github.com