Files
praca_inzynierska/Thesis/Chapters/4. Tests.typ
T
2026-05-26 22:44:10 +02:00

293 lines
14 KiB
Typst

#import "../style.typ"
#set heading(numbering: "1.1")
= Testowanie i weryfikacja <testy>
W celu zapewnienia poprawności działania zaimplementowanej aplikacji oraz weryfikacji spełnienia przyjętych wymagań funkcjonalnych i niefunkcjonalnych, przeprowadziłem testy obejmujące warstwę logiki biznesowej, kodowania danych oraz interakcje między urządzeniami. Poniższy rozdział opisuje przyjętą metodologię testowania, zaimplementowane testy jednostkowe, scenariusze testowe przeprowadzone w środowisku rzeczywistym oraz analizę wydajności i zużycia zasobów systemowych.
== Metodologia testowania
1. Testy jednostkowe - weryfikacja izolowanych komponentów logiki biznesowej z wykorzystaniem zastępczych implementacji zależności. Do ich implementacji wykorzystano framework Swift Testing, dostępny od wersji Xcode 16 oraz systemów operacyjnych z rodziny iOS/iPadOS 18. Framework ten oferuje nowoczesną składnię opartą na atrybutach, wbudowane wsparcie dla testów parametrycznych.
2. Testy integracyjne i scenariuszowe - ręczna weryfikacja poprawności komunikacji peer-to-peer, odkrywania urządzeń oraz synchronizacji notatek w rzeczywistym środowisku sieciowym (Wi-Fi oraz Bluetooth).
3. Testy wydajnościowe - pomiar czasów propagacji zmian, zużycia pamięci operacyjnej oraz obciążenia procesora w trakcie działania aplikacji.
Testy jednostkowe zostały wykonane w izolacji od systemu plików oraz frameworka Multipeer Connectivity poprzez wstrzyknięcie abstrakcji `StorageProvider`. Dzięki temu możliwa była szybka i powtarzalna weryfikacja logiki przechowywania notatek bez konieczności przygotowywania fizycznego katalogu na dysku.
== Testy jednostkowe
Podstawowym przetestowanym komponentem jest klasa `NotesStorage`, odpowiedzialna za zarządzanie cyklem życia notatek w lokalnym systemie plików. Do testów przygotowałem zastępczą implementację `InMemoryStorageProvider` (algorytm 4.1), która symuluje zachowanie systemu plików w pamięci operacyjnej. Implementacja ta przechowuje pliki w słowniku, udostępniając zawartość katalogu oraz tworzenia plików zgodnie z protokołem `StorageProvider`.
#let in_memory_storage = [```swift
final class InMemoryStorageProvider: StorageProvider {
private(set) var files: [String: Data] = [:]
func contentsOfDirectory(atPath path: String) throws -> [String] {
return files.keys.compactMap { filePath -> String? in
guard filePath.hasPrefix(path) else { return nil }
let remainder = String(filePath.dropFirst(path.count))
let stripped = remainder.hasPrefix("/") ? String(remainder.dropFirst()) : remainder
guard !stripped.isEmpty, !stripped.contains("/") else { return nil }
return stripped
}
}
@discardableResult
func createFile(
atPath path: String,
contents data: Data?,
attributes attr: [FileAttributeKey: Any]?
) -> Bool {
files[path] = data ?? Data()
return true
}
}
```]
#figure(
in_memory_storage,
kind: raw,
caption: [Implementacja zastępczego StorageProvider dla testów jednostkowych],
)
Na bazie powyższego kodu zbudowałem pięć przypadków testowych klasy `NotesStorageTests` (algorytm 4.2). Pierwszy z nich weryfikuje, że przy pustym katalogu metoda `loadNotes()` zwraca pustą tablicę. Kolejne dwa testy sprawdzają poprawność tworzenia pliku oraz odczytu istniejącej notatki. Ostatni test sprawdza, czy dwukrotne wywołanie metody tworzącej notatkę o tej samej nazwie generuje dwa odrębne obiekty.
#let notes_storage_tests = [```swift
@Suite
struct NotesStorageTests {
private func makeStorage(root: URL = URL(filePath: "/test")) -> (NotesStorage, InMemoryStorageProvider) {
let provider = InMemoryStorageProvider()
let storage = NotesStorage(storageProvider: provider, rootDirectory: root)
return (storage, provider)
}
@Test
func loadNotesReturnsEmptyArrayWhenNoFiles() {
let (storage, _) = makeStorage()
let notes = storage.loadNotes()
#expect(notes.isEmpty)
}
@Test
func createNoteCreatesFile() {
let root = URL(filePath: "test")
let (storage, provider) = makeStorage(root: root)
storage.createNote(name: "My note")
#expect(!provider.files.isEmpty)
}
@Test
func loadNotesReturnCreatedNote() {
let root = URL(filePath: "/test")
let (storage, provider) = makeStorage(root: root)
let filePath = root.appendingPathComponent("Note").path
provider.createFile(atPath: filePath, contents: Data(), attributes: nil)
let notes = storage.loadNotes()
#expect(notes.count == 1)
#expect(notes.first?.name == "Note")
}
@Test
func createNoteDeduplicatesNames() {
let root = URL(filePath: "/test")
let (storage, provider) = makeStorage(root: root)
let existingPath = root.appendingPathComponent("Note").path
provider.createFile(atPath: existingPath, contents: Data(), attributes: nil)
storage.createNote(name: "Note")
#expect(provider.files.count == 2)
let paths = provider.files.keys
#expect(paths.contains(where: { $0.contains("Note 1") }))
}
@Test
func createNoteTwiceCreatesTwoFiles() {
let root = URL(filePath: "/test")
let (storage, _) = makeStorage(root: root)
storage.createNote(name: "Note")
storage.createNote(name: "Note")
let notes = storage.loadNotes()
#expect(notes.count == 2)
}
}
```]
#figure(
notes_storage_tests,
kind: raw,
caption: [Testy jednostkowe dla NotesStorage],
)
=== Weryfikacja kodowania danych sieciowych
Komunikacja między urządzeniami wymaga poprawnej serializacji i deserializacji obiektów domenowych do formatu JSON. W ramach testów jednostkowych zweryfikowano dwie kluczowe struktury: `NoteMessage` oraz `NoteInvitation.NoteContent`.
`NoteMessageCodableTests` (algorytm 4.3) zawiera trzy przypadki testowe. Pierwszy sprawdza, czy obiekt jest poprawnie kodowany do formatu JSON z zachowaniem oczekiwanych nazw kluczy (`senderID`, `content`). Drugi weryfikuje poprawność dekodowania z ciągu znaków JSON. Trzeci test polega na zakodowaniu obiektu, potem zdekodowaniu i porównaniu go z obiektem oryginalnym.
#let note_message_codable_tests = [```swift
@Suite
struct NoteMessageCodableTests {
@Test
func encodesToJSON() throws {
let message = NoteMessage(senderID: "host", content: "Content")
let data = try JSONEncoder().encode(message)
let json = try #require(try? JSONSerialization.jsonObject(with: data) as? [String: String])
#expect(json["senderID"] == "host")
#expect(json["content"] == "Content")
}
@Test
func decodesFromJSON() throws {
let json = #"{"senderID":"host","content":"contentt"}"#
let data = try #require(json.data(using: .utf8))
let message = try JSONDecoder().decode(NoteMessage.self, from: data)
#expect(message.senderID == "host")
#expect(message.content == "contentt")
}
@Test("encode → decode")
func coding() throws {
let original = NoteMessage(senderID: "host", content: "coding test")
let data = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(NoteMessage.self, from: data)
#expect(decoded.senderID == original.senderID)
#expect(decoded.content == original.content)
}
}
```]
#figure(
note_message_codable_tests,
kind: raw,
caption: [Testy kodowania i dekodowania NoteMessage],
)
Analogiczny zbiór testów został przygotowany dla struktury `NoteInvitation.NoteContent` (algorytm 4.4), która reprezentuje migawkę notatki przesyłaną w zaproszeniu do sesji.
#let note_content_codable_tests = [```swift
@Suite
struct NoteContentCodableTests {
@Test("encode → decode")
func coding() throws {
let original = NoteInvitation.NoteContent(title: "My note", noteSnapshot: "Note Sample Snapshot")
let data = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(NoteInvitation.NoteContent.self, from: data)
#expect(decoded.title == original.title)
#expect(decoded.noteSnapshot == original.noteSnapshot)
}
@Test
func containsExpectedKeys() throws {
let content = NoteInvitation.NoteContent(title: "Titlee", noteSnapshot: "Snapshott")
let data = try JSONEncoder().encode(content)
let json = try #require(try? JSONSerialization.jsonObject(with: data) as? [String: String])
#expect(json["title"] == "Titlee")
#expect(json["noteSnapshot"] == "Snapshott")
}
}
```]
#figure(
note_content_codable_tests,
kind: raw,
caption: [Testy kodowania i dekodowania NoteContent],
)
`NoteInvitationTests` (algorytm 4.5) obejmuje siedem przypadków testowych. Dwa pierwsze weryfikują, czy metody `accept()` oraz `decline()` przekazują odpowiednio wartości logiczne `true` i `false` do handlera. Kolejne dwa testy sprawdzają czy wielokrotne wywołanie tej samej metody nie powoduje powtórnego wywołania handlera. Piąty test gwarantuje, że po zaakceptowaniu zaproszenia próba jego odrzucenia jest ignorowana. Ostatnie dwa testy weryfikują poprawność obliczanych właściwości: `noteName` zwraca tytuł notatki, a `id` jest tożsame z identyfikatorem nadawcy (`MCPeerID`).
#let note_invitation_tests = [```swift
@Suite
struct NoteInvitationTests {
@Test
func acceptCallsHandlerWithTrue() {
var received: Bool? = nil
var invitation = NoteInvitation { received = $0 }
invitation.accept()
#expect(received == true)
}
@Test
func declineCallsHandlerWithFalse() {
var received: Bool? = nil
var invitation = NoteInvitation { received = $0 }
invitation.decline()
#expect(received == false)
}
@Test
func acceptIsIdempotent() {
var callCount = 0
var invitation = NoteInvitation { _ in callCount += 1 }
invitation.accept()
invitation.accept()
#expect(callCount == 1)
}
@Test
func declineIsIdempotent() {
var callCount = 0
var invitation = NoteInvitation { _ in callCount += 1 }
invitation.decline()
invitation.decline()
#expect(callCount == 1)
}
@Test
func declineAfterAcceptIsNoOp() {
var callCount = 0
var invitation = NoteInvitation { _ in callCount += 1 }
invitation.accept()
invitation.decline()
#expect(callCount == 1)
}
@Test
func noteNameReturnsTitle() {
let invitation = NoteInvitation { _ in }
#expect(invitation.noteName == "Shared note")
}
@Test
func idIsInvitatorPeerID() {
let peerID = MCPeerID(displayName: "host")
let invitation = NoteInvitation(
invitatorID: peerID,
note: .init(title: "T", noteSnapshot: "S")
)
#expect(invitation.id == peerID)
}
}
```]
#figure(
note_invitation_tests,
kind: raw,
caption: [Testy jednostkowe NoteInvitation],
)
== Scenariusze testowe
Ze względu na złożoność oraz niedeterministyczny charakter frameworka Multipeer Connectivity, część funkcjonalności aplikacji wymagała ręcznej weryfikacji w rzeczywistym środowisku. Testy scenariuszowe przeprowadzono na dwóch fizycznych urządzeniach: iPhone 16 Pro (iOS 26) oraz iPad Air 2020 (iPadOS 26), połączonych wspólną siecią Wi-Fi oraz z włączonym modułem Bluetooth. Odległość między urządzeniami wynosiła około 2 metry.
*Scenariusz 1: Odkrywanie urządzeń w sieci lokalnej.*
Celem była weryfikacja wymagania funkcjonalnego dotyczącego rozgłaszania obecności oraz wykrywania innych klientów. Użytkownik A (serwer) otworzył ekran edycji notatki, natomiast użytkownik B (klient) pozostawał na ekranie głównym. Oczekiwanym rezultatem było pojawienie się identyfikatora użytkownika B na liście dostępnych klientów w ekranie zarządzania członkami. Test zakończył się sukcesem - urządzenie B zostało wykryte w ciągu mniej niż 2 sekund.
*Scenariusz 2: Wysyłanie i akceptacja zaproszenia.*
Użytkownik A wysłał zaproszenie do edycji notatki użytkownikowi B. Na urządzeniu B pojawiło się powiadomienie o zaproszeniu z prawidłowym tytułem notatki. Po akceptacji notatka pojawiła się w sekcji "External notes" na urządzeniu B, a użytkownik A otrzymał informację o dołączeniu klienta do sesji poprzez zmianę status obok nazwy na "Joined".
*Scenariusz 3: Synchronizacja treści notatki w czasie rzeczywistym.*
Użytkownik A dokonywał zmian w treści notatki, podczas gdy użytkownik B miał otwartą samą notatkę w trybie edycji. Zmiany wprowadzane przez użytkownika A pojawiały się na urządzeniu użytkownika B z opóźnieniem nieprzekraczającym 1s.
== Analiza wydajności i zużycia zasobów
Weryfikacja wymagań niefunkcjonalnych została uzupełniona o pomiary wydajnościowe przeprowadzone przy użyciu narzędzi Instruments dostarczanych wraz z Xcode.
Średnie zużycie pamięci RAM przy uruchomionej aplikacji, bez aktywnej sesji edycji, wynosiło 20 MB. Podczas aktywnej sesji peer-to-peer z dwoma połączonymi klientami zużycie wzrastało do 30 MB. Wzrost ten jest spowodowany głównie utrzymywaniem obiektów związanych z sesją `MCSession` oraz obsługą komponentu edycji tekstu `TextEditor`. Nie zaobserwowano wycieków pamięci w trakcie użytkowania.
Obciążenie procesora w stanie spoczynku wynosiło 0-1%, gdzie przedział maksymalny wynosi 0-600%, ze względu na obecność 6 rdzenii, których zużycie, w zakresie 0-100%, się sumuje. Podczas intensywnej edycji tekstu z jednoczesną synchronizacją obciążenie wzrastało do 30-40% na urządzeniu iPhone 16 Pro. Głównym źródłem obciążenia była obsługa edycji tekstu oraz wysyłanie danych przez framework Multipeer Connectivity.
Podsumowując - przeprowadzone testy jednostkowe, scenariuszowe oraz wydajnościowe potwierdzają, że zaimplementowana aplikacja spełnia przyjęte wymagania funkcjonalne i niefunkcjonalne. Architektura oparta na protokole `StorageProvider` umożliwiła efektywne testowanie logiki przechowywania, natomiast manualne scenariusze wykazały stabilność komunikacji P2P w typowych warunkach użytkowania.