Diversity w polskim IT
Marek Niedbach
Silvair
Marek NiedbachSenior Mobile Engineer @ Silvair

Jak testować aplikacje pracujące z hardwarem

Zobacz porównanie 3 sposobów testowania aplikacji pracujących z urządzeniami na przykładzie Bluetooth.
25.04.20197 min
Jak testować aplikacje pracujące z hardwarem

Aby dostarczać wysokiej jakości oprogramowanie, niezbędne jest posiadanie sposobu na weryfikację poprawności jego działania. Zamiast przeprowadzać żmudne testy manualne, wszyscy dążymy do automatyzacji tego procesu. W związku z tym powstają testy na różnych poziomach: od jednostkowych, sprawdzających poprawne działanie poszczególnych klas, poprzez integracyjne, weryfikujące wzajemną komunikację pomiędzy modułami, aż po black-boxowe testy end-to-end (E2E), oceniające działanie całego systemu od strony użytkownika.

Sprawa jest dosyć prosta w zwykłych aplikacjach biznesowych. Piszemy mnóstwo testów jednostkowych, średnią liczbę integracyjnych i kilka testów E2E. Dodatkowo inwestujemy w serwisy cloudowe CI/CD, które uruchamiają nasze testy, a po ich pomyślnym zakończeniu, dostarczają aplikacje użytkownikom.

Niestety wszystko się komplikuje, gdy nasza aplikacja korzysta z dobrodziejstw telefonu, takich jak aparat, czy adapter bluetooth. Aplikacje te nie mogą już być uruchamiane na symulatorach - potrzebujemy fizycznego urządzenia do testów. W tym artykule chciałbym przedstawić możliwe podejścia do problemu, a także pokrótce opisać, w jaki sposób możemy stworzyć własne środowisko dla testów automatycznych tego typu aplikacji.

Testowanie jednostkowe i integracyjne samej aplikacji

Jeśli nie chcemy budować dedykowanego środowiska do testów bądź też chcemy móc uruchamiać nasze testy w zewnętrznym serwisie, musimy odseparować warstwę sprzętową od samej aplikacji. Niestety bardzo często zdarza się, że klasy w bibliotekach są finalne, a więc uniemożliwiają tworzenie mocków. Ponadto pamiętajmy też o jednej z zasad pisania dobrych testów:

Don’t mock types you don’t own.

Z pomocą przychodzi nam jeden ze wzorców projektowych o angielskiej nazwie Humble Object. Polega on na tym, że tworzymy obiekt (bądź obiekty) na granicy naszej aplikacji, w miejscu bezpośredniego styku z serwisami zewnętrznymi. Ważne też jest to, że obiekt ten nie powinien posiadać żadnej logiki - jest to tylko swego rodzaju proxy, przez które aplikacja komunikuje się ze światem.

Posłużmy się przykładem, opierając go o wykorzystywanie CBCentralManagera do skanowania urządzeń Bluetooth. Korzystając z tej klasy bezpośrednio z aplikacji, komunikacja najczęściej odbywa się w następujący sposób:

Jak widać, klasa skanera jest ściśle powiązana z klasami wewnątrz biblioteki, uniemożliwiając tym samym dobre jego testowanie.

Dodając do naszej aplikacji Humble Object na poziomie komunikacji z frameworkiem, jesteśmy w stanie odseparować naszą logikę od zależności zewnętrznych. Poglądowy diagram klas wyglądać może następująco:

Diagram tylko z pozoru wydaje się bardziej złożony. Poniżej przedstawię przykładowy sposób zaimplementowania CentralManagera. Możemy zauważyć, że logika w tej klasie jest obniżona do minimum, niezbędnego do poprawnego działania samej klasy.

Najpierw określimy sobie protokół, w jaki sposób będziemy otrzymywać eventy z adaptera:

protocol CentralManagerDelegate: class {
    func centralManager(_: CentralManager, didConnect: Peripheral)
    func centralManager(_: CentralManager, didDisconnectPeripheral: Peripheral, error: Error?)
    func centralManager(_: CentralManager, didFailToConnect: Peripheral, error: Error?)
    func centralManager(_: CentralManager, didDiscover: Peripheral, advertisementData: [String: Any], rssi: NSNumber)
    func centralManagerDidUpdateState(_: CentralManager)
}


Następnie zaimplementujemy wrapper, oddzielając tym samym bezpośrednie korzystanie z CBCentralMangera:

enum CentralManagerState {
    case poweredOn
    case poweredOff
    case unknown
}
class CentralManager: NSObject {
    var central: CBCentralManager?
    var peripherals = [UUID: Peripheral]()
    weak var delegate: CentralManagerDelegate?
 
    class func makeCentralManager(delegate: CentralManagerDelegate) -> CentralManager {
        let centralManager = CentralManager()
        centralManager.delegate = delegate
        centralManager.central = CBCentralManager(delegate: centralManager, queue: nil, options: nil)
        return centralManager
    }
 
    func connect(_ peripheral: Peripheral) {
        guard let cbPeripheral = peripheral.cbPeripheral else { return }
        central?.connect(cbPeripheral)
    }
 
    func cancelPeripheralConnection(_ peripheral: Peripheral) {
        guard let cbPeripheral = peripheral.cbPeripheral else { return }
        central?.cancelPeripheralConnection(cbPeripheral)
    }
 
    func scanForPeripherals() {
        central?.scanForPeripherals(withServices: nil, options: nil)
    }
 
    func stopScan() {
        central?.stopScan()
    }
 
    func state() -> CentralManagerState {
        switch central?.state {
        case .poweredOn?:
            return .poweredOn
        case .poweredOff?:
            return .poweredOff
        default:
            return .unknown
        }
    }
 
    private func makePeripheral(_ cbPeripheral: CBPeripheral) -> Peripheral {
        if let peripheral = peripherals[cbPeripheral.identifier] {
            return peripheral
        }
        let peripheral = Peripheral(cbPeripheral)
        peripherals[cbPeripheral.identifier] = peripheral
        return peripheral
    }
}


A na koniec połączmy eventy przychodzące z managera do naszego własnego delegata:

extension CentralManager: CBCentralManagerDelegate {
    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        delegate?.centralManager(self, didConnect: makePeripheral(peripheral))
    }
 
    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
        delegate?.centralManager(self, didDisconnectPeripheral: makePeripheral(peripheral), error: error)
    }
 
    func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
        delegate?.centralManager(self, didFailToConnect: makePeripheral(peripheral), error: error)
    }
 
    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi: NSNumber) {
        delegate?.centralManager(self, didDiscover: makePeripheral(peripheral), advertisementData: advertisementData, rssi: rssi)
    }
 
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        delegate?.centralManagerDidUpdateState(self)
    }
}


Takie odseparowanie biblioteki od samej aplikacji ma wiele korzyści. Jesteśmy w stanie bardzo łatwo napisać test double dla powyższych klas i użyć ich w testach jednostkowych. Co więcej, dobrze napisany stub może być wykorzystany również podczas testów UI dla naszego projektu, ponieważ działa szybko, przewidywalnie i może być uruchamiany na symulatorze.

Niestety, to rozwiązanie ma też swoje wady. Testowanie aplikacji w odseparowaniu od prawdziwej warstwy systemowej powoduje, że nie do końca jesteśmy pewni, czy tak samo będzie się ona zachowywać na telefonie czy tablecie - czasem nie jesteśmy w stanie przewidzieć wszystkich scenariuszy, w jaki sposób zachowa się system operacyjny.

Testy integracyjne aplikacji z wykorzystaniem fizycznego połączenia radiowego

Jeśli chcemy przetestować działanie aplikacji wraz z wykorzystaniem przez nią połączenia radiowego, musimy przygotować sobie takie urządzenie końcowe. W ten sposób jesteśmy w stanie zasymulować różnego rodzaju urządzenia, zarówno poprawne, jak i niepoprawne i sprawdzić, jak zachowa się nasza aplikacja.

Urządzenia Bluetooth Low Energy komunikują się z aplikacją w jeden z dwóch sposobów:

  • Advertisement - urządzenie takie broadcastuje paczkę, którą może usłyszeć dowolny skaner w pobliżu, np. telefon z naszą aplikacją.
  • Connection - posiada listę serwisów, charakterystyk i deskryptorów, które mogą być czytane i zapisywane przez urządzenie, które się do niego połączyło.


Niestety, do tego potrzebujemy już nieco więcej sprzętu w naszym środowisku do testów. Minimalnym, a zarazem najbardziej optymalnym wymogiem są:

  • Komputer z systemem Linux - niestety systemy Mac OS oraz Windows posiadają pewne restrykcje dotyczące dostępu do adaptera bluetooth, umożliwiające symulowanie urządzenia w pełni.
  • USB Dongle z Bluetooth 4.0+, możemy również próbować skonfigurować wbudowany w komputer adapter, jednak zawsze było to dla mnie bardziej problematyczne, niż użycie urządzenia zewnętrznego.


Mając przygotowane środowisko, możemy przystąpić do działania. Możliwości implementacji własnego peripherala jest wiele. Ja przedstawię implementację opartą o Node.js oraz bibliotekę bleno.

Konfigurację komputera możemy znaleźć bezpośrednio na stronie biblioteki, więc nie będzie to częścią tego artykułu. Skupię się tylko na wykorzystaniu jej do zasymulowania urządzenia. Jeśli chcemy przetestować działanie skanera w naszej aplikacji, wystarczy, że uruchomimy poniższe kilka linijek:

var bleno = require('bleno');

bleno.on('stateChange', function(state) {
	if (state === 'poweredOn') {
		var advertisementData = new Buffer('020106090648656c6c6f21', 'hex')
		bleno.startAdvertisingWithEIRData(advertisementData)
	}
});


Tylko tyle kodu wystarczy, aby rozpocząć rozgłaszanie się urządzenia, a konkretniej urządzenie zacznie rozgłaszać się pod nazwą “Hello!” - nazwa ta jest częścią advertisementData, i zapisana w postaci szesnastkowej ASCII. W kwestii struktury tych danych i ich znaczenia odsyłam do specyfikacji Core Bluetooth.

Mając przygotowane środowisko i symulator urządzenia, musimy jeszcze móc nim jakoś zarządzać z poziomu testów. Jeśli mamy odpowiednie umiejętności, możemy rozbudować skrypty js do bleno o dodatkowe API, do którego będzie można wysyłać requesty z poziomu testów.

Bardzo często jednak tego typu testy pisane są niezależnie od samej aplikacji, na przykład w Pythonie + Behave, a do “symulowania” kliknięć w aplikacji wykorzystuje się bibliotekę Appium. Tego typu rozwiązanie pozwala nam sterować napisanym przez nas symulatorem urządzenia bezpośrednio z testów, które wykonywane są na komputerze. Jest to łatwiejsze i mniej pracochłonne niż przygotowanie API i łączenie się do niego z poziomu testów wykonywanych bezpośrednio na telefonie.

Kompleksowe testy end-to-end z wykorzystaniem prawdziwych urządzeń

Trzecim, najbardziej pracochłonnym, a zarazem zaawansowanym sposobem testowania jest pełne środowisko end-to-end. W dużej mierze pokrywa się z testami wyżej opisanymi, jednak zamiast symulowania urządzenia, wykorzystuje się już końcowy produkt. Tego typu testy dają najwięcej pewności co do działania aplikacji z prawdziwymi urządzeniami. Dodatkowo jeśli jesteśmy twórcami tych urządzeń, możemy pokusić się o dodanie dodatkowego interfejsu diagnostycznego, umożliwiającego wprowadzenie urządzenia w określony stan.

Niestety, testy te są najczęściej najwolniejsze, co powoduje, że suita testowa obejmuje tylko najważniejsze funkcjonalności. Bardzo rzadko możemy pokusić się tutaj o testowanie warunków brzegowych, czy innych specyficznych przypadków.

Jakie testy są więc najlepsze?

Zanim na to pytanie odpowiemy, wymieńmy po kilka plusów i minusów każdego ze sposobu testowania.

Testowanie samej aplikacji

+ Szybkie
+ Mogą być uruchamiane na symulatorach i w usługach cloudowych
- Możemy “rozminąć się” z działaniem adaptera poprzez odcięcie go od testów
- Nie wiemy, czy aplikacja działa z prawdziwym produktem

Testowanie aplikacji z symulowanym urządzeniem

+ Wykorzystujemy adapter bluetooth w telefonie
+ Możemy symulować urządzenia firm trzecich
+ Możemy symulować urządzenie w różnym stanie, np. niepoprawnie działające
- Wolne
- Wymagają dedykowanego środowiska do testów

Testowanie aplikacji z prawdziwym urządzeniem

+ Wykorzystujemy adapter bluetooth w telefonie
+ Sprawdzamy końcowe rozwiązanie
- Trudno wprowadzić urządzenia w specjalny stan do testów, bez posiadania interfejsu diagnostycznego
- Wolne lub nawet bardzo wolne, w zależności od parametrów urządzeń
- Wymagane dedykowane środowisko do testów

Patrząc na plusy i minusy każdego sposobu testowania, najlepiej mieć wszystkie rodzaje testów. Każdy z nich, o ile jest dobrze napisany, daje nam pewną wartość. Niektóre są szybkie, dzięki czemu możemy przetestować dużo warunków brzegowych w krótkim czasie. Inne są wolne i wymagają odpowiedniego środowiska do działania, ale dają nam informację, jak nasze rozwiązanie działa w całości.

W zależności od tego, co chcemy testować, jakie mamy umiejętności i możliwości stworzenia środowiska do testów, powinniśmy wybrać optymalny sposób testowania. Dużo ważniejsze jest zapewnienie, aby testy testowały to, co powinny testować i co chcemy, by testowały, niż sposób, w jaki to robią. Bez sensu jest pisać dziesiątki testów E2E, sprawdzających każde przypadki, jeśli wykonywać się mają kilka dni, i będziemy je uruchamiać raz na tydzień.

O autorze

Marek Niedbach to Senior Mobile Engineer w Silvair. Zawodowo pasjonat CleanCode oraz TDD, a także metodologii KISS. Prywatnie zafascynowany grami planszowymi i fabularnymi oraz całodniowymi wędrówkami górskimi. Stara się zawsze pokonywać swoje słabości, gdyż uważa, że nie ma rzeczy niemożliwych.

<p>Loading...</p>