Простой, но мощный трюк SwiftUI

Чтобы хорошо начать тестирование SwiftUI, используя ViewInspector framework, вы можете прочитать это или это.

Одним из элементов, описанных в первом руководстве (и на странице ViewInspector GitHub), является использование @State в представлении, которое вы хотите протестировать.

В принципе, если ваше мнение выглядит примерно так:

struct ContentView: View {
   @State var numClicks:Int = 0
 
   var body: some View {
      VStack{
         Button("Click me"){
            numClicks += 1
         }.id("Button1")
         Text("\(numClicks)")
           .id("Text1")
           .padding()
   
      }
   }
}

На самом деле невозможно проверить действие нажатия кнопки на numClicks.
Приемлемый обходной путь — добавить немного кода в представление, преобразуя его в основном в:

struct ContentView: View {
   @State var numClicks:Int = 0
   internal let inspection = Inspection<Self>()
 
   var body: some View {
      VStack{
         Button("Click me"){
            numClicks += 1
         }.id("Button1")
         Text("\(numClicks)")
           .id("Text1")
           .padding()
   
       }.onReceive(inspection.notice) { 
            self.inspection.visit(self, $0) }
    }
}

где Инспекция:

internal final class Inspection<V> {
   let notice = PassthroughSubject<UInt, Never>()
   var callbacks: [UInt: (V) -> Void] = [:]
   func visit(_ view: V, _ line: UInt) {
      if let callback = callbacks.removeValue(forKey: line) {
         callback(view)
      }
   }
}
extension Inspection: InspectionEmissary {}

Как вы можете видеть, ContentView получил новое свойство Inspection, и onReceive его тела получил указание запустить метод посещения свойства Inspection, когда данные публикуются для замеченного издателя.

Это решает проблему, и работающий тестовый пример может выглядеть так:

func testContentView() throws{
   let sut = ContentView()
    _ = sut.inspection.inspect { view in
       let button = try view.find(viewWithId: “Button1”).button()
       try button.tap()
       XCTAssertEqual(try view.actualView().numClicks, 1)
       let text = try view.find(viewWithId: “Text1”).text()
       let value = try text.string()
       XCTAssertEqual(value, “1”)
    }
 }

Тем не менее, добавление тестовых «функций» в рабочий код выглядит довольно уродливо. Это может сделать сложное представление еще более сложным, и в нем много избыточного кода, который необходимо скопировать/вставить в каждое представление, которое вы хотите протестировать.

Поэтому я попытался найти что-то более чистое, что добавило бы немного накладных расходов на код тестирования, но оставило бы реальную реализацию представления нетронутой.

Сначала я реализовал простое представление-оболочку, которое могло бы добавить функциональность проверки, необходимую для ViewInspector:

public let TEST_WRAPPED_ID: String = “wrapped”
struct TestWrapperView<Wrapped: View> : View{
   internal let inspection = Inspection<Self>()
   var wrapped: Wrapped
 
   init( wrapped: Wrapped ){
       self.wrapped = wrapped
   }
 
   var body: some View {
      wrapped
        .id(TEST_WRAPPED_ID)
        .onReceive(inspection.notice) { 
           self.inspection.visit(self, $0) 
        }
    }
}
extension TestWrapperView: Inspectable{}

С этим представлением-оболочкой возможно тестирование представления с использованием исходной реализации ContentView:

func testContentView() throws{
   let sut = TestWrapperView(wrapped: ContentView())
   _ = sut.inspection.inspect { view in
       let wrapped = try view.find(viewWithId: TEST_WRAPPED_ID)
       let button = try wrapped.find(viewWithId: “Button1”).button()
       try button.tap()
       let numClicks = try wrapped
                         .view(ContentView.self)
                         .actualView()
                         .numClicks
       XCTAssertEqual(numClicks, 1)
       let text = try wrapped.find(viewWithId: “Text1”).text()
       let value = try text.string()
       XCTAssertEqual(value, “1”)
    }
 }

Надеюсь, вам понравится этот маленький трюк, который немного облегчит тестирование представлений SwiftUI.