Γράφουμε ένα unit test. Ο κώδικάς μας χρησιμοποιεί ένα repository, μια βάση δεδομένων ή μια υπηρεσία ειδοποιήσεων.
Και κάπου εκεί εμφανίζεται η ερώτηση:
Να χρησιμοποιήσω fake ή mock; Ποια είναι η διαφορά και ποιο κάνει καλύτερο το test μου;
Ένα fake μάς δίνει μια απλοποιημένη υλοποίηση που λειτουργεί. Ένα mock μάς επιτρέπει να επαληθεύσουμε συγκεκριμένες κλήσεις προς έναν συνεργάτη.
Η επιλογή εξαρτάται από αυτό που θέλουμε να αποδείξουμε:
- Αν ενδιαφερόμαστε για το αποτέλεσμα μιας αλλαγής, ένα fake συχνά κάνει το test πιο φυσικό.
- Αν ενδιαφερόμαστε για μια συγκεκριμένη εξωτερική ενέργεια, ένα mock μπορεί να εκφράζει ακριβώς την απαίτηση.
- Αν μια πραγματική υλοποίηση είναι ήδη απλή, γρήγορη και προβλέψιμη, μπορούμε να τη χρησιμοποιήσουμε απευθείας.
Ας δούμε τι σημαίνουν αυτά, πάνω στο ίδιο παράδειγμα. Ο κώδικας χρησιμοποιεί Python 3.10+ και το ενσωματωμένο unittest, χωρίς επιπλέον βιβλιοθήκες. Οι αρχές εφαρμόζονται και σε άλλες γλώσσες και frameworks.
Τι ελέγχει ένα unit test
Ένα unit test ελέγχει μια συγκεκριμένη συμπεριφορά μιας μικρής μονάδας λογισμικού.
Η μονάδα μπορεί να είναι μια συνάρτηση, μια κλάση ή ένα μικρό σύνολο αντικειμένων που συνεργάζονται. Δεν υπάρχει ένας καθολικά αποδεκτός κανόνας ότι «ένα unit test επιτρέπεται να εκτελεί μόνο μία κλάση». Ο Martin Fowler περιγράφει τόσο tests με πραγματικούς συνεργάτες όσο και tests που τους αντικαθιστούν με test doubles. Unit Test — Martin Fowler
Για παράδειγμα, σε μια εφαρμογή διαχείρισης εργασιών, διαφορετικές συμπεριφορές είναι:
- Μια ανολοκλήρωτη εργασία γίνεται ολοκληρωμένη.
- Η νέα κατάσταση αποθηκεύεται.
- Στέλνεται ειδοποίηση ολοκλήρωσης.
- Μια εργασία που δεν υπάρχει προκαλεί συγκεκριμένο σφάλμα.
- Η επανάληψη της ενέργειας δεν στέλνει δεύτερη ειδοποίηση.
Παρότι όλες μπορεί να σχετίζονται με μία μέθοδο, αξίζουν ξεχωριστά, εστιασμένα tests.
Ένα καλό test περιγράφει ποια απαίτηση ισχύει και υπό ποιες συνθήκες.
Η οικογένεια των test doubles
Το test double είναι ο γενικός όρος για ένα αντικείμενο που αντικαθιστά κάποιο άλλο κατά τη διάρκεια ενός test.
Δεν είναι όλα τα test doubles mocks.
Η παρακάτω ορολογία ακολουθεί τη διάκριση που παρουσιάζει ο Fowler, βασισμένος στο έργο του Gerard Meszaros. Mocks Aren’t Stubs
| Είδος | Ρόλος | Απλό παράδειγμα |
|---|---|---|
| Dummy | Συμπληρώνει μια παράμετρο και δεν χρησιμοποιείται στο συγκεκριμένο test. | Ένα αντικείμενο που απαιτεί ο constructor, αλλά δεν καλείται. |
| Stub | Δίνει προκαθορισμένες απαντήσεις. | «Όταν ζητηθεί η εργασία, επέστρεψε αυτή την τιμή». |
| Fake | Παρέχει μια απλοποιημένη λειτουργική υλοποίηση. | Repository που αποθηκεύει αντικείμενα σε ένα dictionary. |
| Spy | Καταγράφει κλήσεις ώστε να τις εξετάσουμε. | Καταγραφέας των ειδοποιήσεων που ζητήθηκαν. |
| Mock | Χρησιμοποιείται για επαλήθευση προσδοκιών σχετικά με κλήσεις. | «Η ειδοποίηση πρέπει να κληθεί μία φορά με αυτό το ID». |
Οι ονομασίες των βιβλιοθηκών δεν αντιστοιχούν πάντα ακριβώς σε αυτούς τους ρόλους.
Ένα αντικείμενο που δημιουργήθηκε από το unittest.mock μπορεί να χρησιμοποιείται μόνο ως stub. Αν απλώς επιστρέφει μια τιμή, κάνουμε stubbing. Αν επαληθεύουμε πώς κλήθηκε, κάνουμε interaction verification.
Αντίστοιχα, ένα χειροποίητο αντικείμενο που καταγράφει κλήσεις μπορεί να λειτουργεί ως spy.
Ο ρόλος μέσα στο test έχει περισσότερη σημασία από τον τρόπο δημιουργίας του αντικειμένου.
Η βασική διαφορά Fake και Mock
Ας υποθέσουμε ότι ολοκληρώνουμε μια εργασία.
Με fake repository μπορούμε να ελέγξουμε:
Μετά την εκτέλεση, το repository επιστρέφει την εργασία ως ολοκληρωμένη.
Με mock repository μπορούμε να ελέγξουμε:
Το use case κάλεσε τη μέθοδο αποθήκευσης με την ολοκληρωμένη εργασία.
Στην πρώτη περίπτωση εξετάζουμε την κατάσταση που προέκυψε, δηλαδή κάνουμε state verification. Στη δεύτερη εξετάζουμε μια αλληλεπίδραση, δηλαδή κάνουμε interaction verification.
Αυτά είναι διαφορετικά αποδεικτικά στοιχεία.
Το fake repository μάς επιτρέπει να αποθηκεύσουμε και μετά να διαβάσουμε δεδομένα. Το mock repository καταγράφει ότι ζητήθηκε μια αποθήκευση.
Κανένα από τα δύο, από μόνο του, δεν αποδεικνύει ότι λειτουργεί το πραγματικό SQL query.
Επίσης, οι άξονες δεν είναι απόλυτοι: ένα test μπορεί να χρησιμοποιεί fake και ταυτόχρονα να ελέγχει αλληλεπιδράσεις σε έναν δεύτερο συνεργάτη.
Το παράδειγμα που θα χρησιμοποιήσουμε
Θα υλοποιήσουμε το use case CompleteTask.
Οι απαιτήσεις του είναι:
- Φορτώνει την εργασία.
- Αν δεν υπάρχει, σηκώνει
TaskNotFound. - Αν είναι ήδη ολοκληρωμένη, την επιστρέφει χωρίς νέα ειδοποίηση.
- Διαφορετικά, την ολοκληρώνει και την αποθηκεύει.
- Μετά την επιτυχημένη αποθήκευση, ζητά την αποστολή ειδοποίησης.
Για το repository ορίζουμε ένα μικρό συμβόλαιο:
- Το
get()επιστρέφειNoneόταν το ID δεν υπάρχει. - Το
save()δημιουργεί ή αντικαθιστά την εργασία με το συγκεκριμένο ID. - Μια αποτυχία διαθεσιμότητας αποθήκευσης μπορεί να εκφραστεί ως
RepositoryUnavailable.
Θα χρησιμοποιήσουμε δύο αρχεία:
tasks.py
test_tasks.py
Όλα τα blocks των tests που ακολουθούν μπαίνουν διαδοχικά στο ίδιο test_tasks.py.
Ο κώδικας της εφαρμογής
Στο tasks.py:
from dataclasses import dataclass, replace
from typing import Protocol
@dataclass(frozen=True)
class Task:
id: str
completed: bool = False
def complete(self) -> "Task":
return replace(self, completed=True)
class TaskNotFound(Exception):
pass
class RepositoryUnavailable(Exception):
pass
class TaskRepository(Protocol):
def get(self, task_id: str) -> Task | None:
...
def save(self, task: Task) -> None:
...
class CompletionNotifier(Protocol):
def send(self, task_id: str) -> None:
...
class CompleteTask:
def __init__(
self,
repository: TaskRepository,
notifier: CompletionNotifier,
) -> None:
self._repository = repository
self._notifier = notifier
def execute(self, task_id: str) -> Task:
task = self._repository.get(task_id)
if task is None:
raise TaskNotFound(task_id)
if task.completed:
return task
completed_task = task.complete()
self._repository.save(completed_task)
self._notifier.send(completed_task.id)
return completed_task
Το Task εκφράζει την κατάσταση και τη μετάβαση ολοκλήρωσης.
Το CompleteTask συντονίζει τη φόρτωση, την αποθήκευση και την ειδοποίηση.
Τα TaskRepository και CompletionNotifier περιγράφουν τις δυνατότητες που χρειάζεται το use case. Δεν περιέχουν λεπτομέρειες SQL, HTTP ή κάποιου SDK.
Το Protocol επιτρέπει να περιγράψουμε αυτή τη δομή χωρίς να απαιτούμε κοινή βασική κλάση από κάθε υλοποίηση. Οι δηλώσεις τύπων δεν επιβάλλουν από μόνες τους το επιχειρησιακό συμβόλαιο κατά την εκτέλεση.
Πρώτα δοκιμάζουμε τον απλό κώδικα χωρίς doubles
Η ολοκλήρωση του ίδιου του Task δεν χρειάζεται repository ή υπηρεσία ειδοποιήσεων.
Στην αρχή του test_tasks.py βάζουμε τα imports:
import unittest
from collections.abc import Iterable
from unittest.mock import create_autospec
from tasks import (
CompleteTask,
CompletionNotifier,
RepositoryUnavailable,
Task,
TaskNotFound,
TaskRepository,
)
Και προσθέτουμε:
class TaskTest(unittest.TestCase):
def test_task_can_be_completed_without_test_doubles(self):
task = Task("task-1")
result = task.complete()
self.assertEqual(Task("task-1", completed=True), result)
self.assertFalse(task.completed)
Το test ελέγχει την πραγματική υλοποίηση της συμπεριφοράς.
Ελέγχει επίσης ότι το αρχικό αντικείμενο παραμένει ανολοκλήρωτο: η μέθοδος επιστρέφει νέο αντικείμενο.
Δεν χρειάζεται να αντικαταστήσουμε με mock κάτι που είναι ήδη εύκολο να χρησιμοποιήσουμε.
Ο ίδιος έλεγχος με Fake
Για να ελέγξουμε το use case χωρίς πραγματική βάση, δημιουργούμε ένα repository που κρατά δεδομένα στη μνήμη.
Στο test_tasks.py:
class InMemoryTaskRepository:
def __init__(self, tasks: Iterable[Task] = ()) -> None:
self._tasks = {task.id: task for task in tasks}
def get(self, task_id: str) -> Task | None:
return self._tasks.get(task_id)
def save(self, task: Task) -> None:
self._tasks[task.id] = task
Αυτό είναι fake επειδή έχει λειτουργική συμπεριφορά:
- Αποθηκεύει διαφορετικές εργασίες.
- Αναζητά με βάση το ID.
- Διατηρεί αλλαγές μεταξύ κλήσεων.
- Αντικαθιστά υπάρχουσες εγγραφές.
Δεν χρειάζεται να ορίσουμε ξεχωριστά την απάντηση κάθε κλήσης.
Χρειαζόμαστε επίσης έναν απλό συνεργάτη για τις ειδοποιήσεις:
class RecordingNotifier:
def __init__(self) -> None:
self.sent_task_ids: list[str] = []
def send(self, task_id: str) -> None:
self.sent_task_ids.append(task_id)
Ο RecordingNotifier είναι spy: καταγράφει τις κλήσεις που δέχεται. Δεν υλοποιεί πραγματική παράδοση ειδοποιήσεων.
Το test με το fake repository είναι:
class CompleteTaskWithFakeTest(unittest.TestCase):
def test_completed_task_is_persisted(self):
# Arrange
repository = InMemoryTaskRepository([Task("task-1")])
notifier = RecordingNotifier()
complete_task = CompleteTask(repository, notifier)
# Act
result = complete_task.execute("task-1")
# Assert
expected = Task("task-1", completed=True)
self.assertEqual(expected, result)
self.assertEqual(expected, repository.get("task-1"))
def test_missing_task_raises(self):
repository = InMemoryTaskRepository()
notifier = RecordingNotifier()
complete_task = CompleteTask(repository, notifier)
with self.assertRaises(TaskNotFound):
complete_task.execute("missing")
self.assertEqual([], notifier.sent_task_ids)
Το πρώτο test περιγράφει μια ολοκληρωμένη ακολουθία:
Υπάρχει μια ανολοκλήρωτη εργασία. Την ολοκληρώνω. Διαβάζω την αποθηκευμένη κατάστασή της και είναι ολοκληρωμένη.
Δεν καθορίζει πόσες φορές επιτρέπεται να γίνει ανάγνωση από το repository.
Αν η υλοποίηση αλλάξει εσωτερικά, αλλά διατηρήσει το αποτέλεσμα και το συμβόλαιο, το test έχει περισσότερες πιθανότητες να παραμείνει χρήσιμο χωρίς αλλαγές.
Μια σημαντική παγίδα με mutable αντικείμενα
Το παράδειγμα χρησιμοποιεί Task με frozen=True και πεδία που είναι αμετάβλητες τιμές.
Αυτό αποτρέπει μια συνηθισμένη παγίδα των fake repositories.
Αν το repository επέστρεφε το ίδιο mutable αντικείμενο που κρατά εσωτερικά, ο κώδικας θα μπορούσε να κάνει:
task.completed = True
Η αλλαγή θα φαινόταν αμέσως μέσα στο fake, ακόμη και αν ξεχνούσαμε να καλέσουμε save().
Έτσι, ένα test θα μπορούσε να περνά ενώ η πραγματική αποθήκευση δεν θα λειτουργούσε.
Στο δικό μας παράδειγμα δημιουργείται νέο Task. Αν αφαιρέσουμε το save(), το fake συνεχίζει να περιέχει το παλιό αντικείμενο και το test αποτυγχάνει.
Η συμπεριφορά του fake πρέπει να σχεδιάζεται με προσοχή.
Ο ίδιος έλεγχος με Mock
Ας αντικαταστήσουμε τώρα το repository με ένα mock.
Στο test_tasks.py προσθέτουμε:
class CompleteTaskWithMockTest(unittest.TestCase):
def test_completed_task_is_saved(self):
# Arrange
repository = create_autospec(
TaskRepository, instance=True, spec_set=True
)
repository.get.return_value = Task("task-1")
notifier = create_autospec(
CompletionNotifier, instance=True, spec_set=True
)
complete_task = CompleteTask(repository, notifier)
# Act
result = complete_task.execute("task-1")
# Assert
expected = Task("task-1", completed=True)
self.assertEqual(expected, result)
repository.save.assert_called_once_with(expected)
def test_missing_task_does_not_save_or_notify(self):
repository = create_autospec(
TaskRepository, instance=True, spec_set=True
)
repository.get.return_value = None
notifier = create_autospec(
CompletionNotifier, instance=True, spec_set=True
)
complete_task = CompleteTask(repository, notifier)
with self.assertRaises(TaskNotFound):
complete_task.execute("missing")
repository.save.assert_not_called()
notifier.send.assert_not_called()
def test_failed_save_does_not_notify(self):
repository = create_autospec(
TaskRepository, instance=True, spec_set=True
)
repository.get.return_value = Task("task-1")
repository.save.side_effect = RepositoryUnavailable()
notifier = create_autospec(
CompletionNotifier, instance=True, spec_set=True
)
complete_task = CompleteTask(repository, notifier)
with self.assertRaises(RepositoryUnavailable):
complete_task.execute("task-1")
notifier.send.assert_not_called()
Εδώ η γραμμή:
repository.get.return_value = Task("task-1")
κάνει stubbing: ορίζει μια απάντηση.
Η γραμμή:
repository.save.assert_called_once_with(expected)
κάνει interaction verification: ελέγχει μια κλήση.
Το mock δεν αποθηκεύει την εργασία σε κάποιο λειτουργικό αποθετήριο. Καταγράφει ότι η save() κλήθηκε με το αναμενόμενο αντικείμενο.
Επίσης, το assert_called_once_with() προσθέτει την απαίτηση της μίας κλήσης. Αν το μόνο που ενδιαφέρει το προϊόν είναι η τελική κατάσταση, αυτό μπορεί να είναι αυστηρότερος περιορισμός από όσο χρειάζεται.
Αυτός είναι ένας λόγος να προτιμήσουμε το fake για τον συγκεκριμένο έλεγχο αποθήκευσης.
Γιατί χρησιμοποιούμε autospec και spec_set
Το create_autospec() δημιουργεί doubles με βάση το API που δίνουμε και ελέγχει τις υπογραφές των κλήσεων.
Το spec_set=True περιορίζει την πρόσβαση και την ανάθεση σε γνωστά attributes.
Έτσι, ένα λάθος όνομα μεθόδου ή λανθασμένος αριθμός παραμέτρων είναι πιθανότερο να αποκαλυφθεί άμεσα. Δεν επαληθεύονται, όμως, οι επιχειρησιακοί κανόνες ή οι τύποι επιστροφής από τα type hints. Τεκμηρίωση του unittest.mock
Το autospec μειώνει ορισμένα λάθη στη χρήση του API. Δεν αποδεικνύει ότι το double συμπεριφέρεται όπως ο πραγματικός adapter.
Πότε το mock είναι ιδιαίτερα βολικό
Το τρίτο test προκαλεί αποτυχία αποθήκευσης:
repository.save.side_effect = RepositoryUnavailable()
Και ελέγχει μια σαφή απαίτηση:
Αν η αποθήκευση αποτύχει, δεν πρέπει να ζητηθεί ειδοποίηση ολοκλήρωσης.
Δεν χρειάζεται να προσθέσουμε στο fake repository μηχανισμούς αποτυχίας για κάθε πιθανό σενάριο.
Για έναν μεμονωμένο έλεγχο σφάλματος, ένα μικρό stub ή ένα mock με side_effect μπορεί να είναι η πιο απλή λύση.
Fake και Mock στο ίδιο test
Δεν χρειάζεται να διαλέξουμε μία τεχνική για ολόκληρη την εφαρμογή.
Μπορούμε να χρησιμοποιήσουμε:
- Fake repository για μια λειτουργική διαχείριση κατάστασης.
- Mock notifier για να ελέγξουμε την απαίτηση αποστολής ειδοποίησης.
Προσθέτουμε στο test_tasks.py:
class CompleteTaskHybridTest(unittest.TestCase):
def test_completion_sends_one_notification(self):
repository = InMemoryTaskRepository([Task("task-1")])
notifier = create_autospec(
CompletionNotifier, instance=True, spec_set=True
)
complete_task = CompleteTask(repository, notifier)
complete_task.execute("task-1")
notifier.send.assert_called_once_with("task-1")
def test_repeated_completion_does_not_notify_again(self):
repository = InMemoryTaskRepository([Task("task-1")])
notifier = create_autospec(
CompletionNotifier, instance=True, spec_set=True
)
complete_task = CompleteTask(repository, notifier)
complete_task.execute("task-1")
complete_task.execute("task-1")
notifier.send.assert_called_once_with("task-1")
Εδώ το mock ελέγχει κάτι που έχει άμεση επιχειρησιακή σημασία: να μη ζητηθεί δεύτερη ειδοποίηση για την ίδια ολοκλήρωση.
Το fake repository εξασφαλίζει ότι η δεύτερη εκτέλεση διαβάζει την κατάσταση που αποθήκευσε η πρώτη.
Δεν προγραμματίζουμε χειροκίνητα μια διαφορετική απάντηση για κάθε ανάγνωση. Η αλλαγή προκύπτει από τη λειτουργία του fake.
Το test αποδεικνύει τη συμπεριφορά για αυτές τις δύο διαδοχικές κλήσεις. Οι ταυτόχρονες κλήσεις είναι διαφορετικό σενάριο.
Τα Fakes χρειάζονται δικά τους tests
Ένα fake είναι επιπλέον κώδικας. Μπορεί να περιέχει bugs ή να αποκλίνει από την πραγματική υλοποίηση.
Χρειάζεται επομένως να ελέγχουμε το συμβόλαιό του.
Προσθέτουμε:
class InMemoryTaskRepositoryContractTest(unittest.TestCase):
def setUp(self):
self.repository = InMemoryTaskRepository()
def test_unknown_id_returns_none(self):
self.assertIsNone(self.repository.get("missing"))
def test_saved_task_can_be_read(self):
self.repository.save(Task("task-1"))
self.assertEqual(Task("task-1"), self.repository.get("task-1"))
def test_save_replaces_task_with_same_id(self):
self.repository.save(Task("task-1"))
self.repository.save(Task("task-1", completed=True))
self.assertEqual(
Task("task-1", completed=True),
self.repository.get("task-1"),
)
Αυτά ελέγχουν τρία σημεία του συμβολαίου μας:
- Την απουσία εγγραφής.
- Την ανάγνωση αποθηκευμένης εγγραφής.
- Την αντικατάσταση εγγραφής με ίδιο ID.
Προς το παρόν εκτελούνται μόνο στο fake.
Όταν υπάρχει πραγματικός adapter, μπορούμε να οργανώσουμε αυτούς τους ίδιους ελέγχους ώστε να εκτελούνται και στις δύο υλοποιήσεις, με κατάλληλο setup και καθαρισμό.
Αυτή είναι η αξία των κοινών contract tests: ελέγχουν ότι οι υλοποιήσεις συμφωνούν στις συμπεριφορές που υπόσχεται το API. Δεν απαιτούν από το fake να αναπαράγει κάθε λεπτομέρεια της βάσης. Software Engineering at Google — Test Doubles
Για παράδειγμα, ένα dictionary δεν αποδεικνύει από μόνο του σωστή συμπεριφορά συναλλαγών, constraints ή ταυτόχρονων ενημερώσεων.
Αυτά χρειάζονται ελέγχους με την κατάλληλη πραγματική υποδομή.
Εκτέλεση των παραδειγμάτων
Αφού τοποθετήσουμε όλα τα προηγούμενα blocks στα δύο αρχεία, εκτελούμε από τον φάκελό τους:
python -m unittest -v
Η ολοκληρωμένη σουίτα περιλαμβάνει 11 tests.
Μπορούμε επίσης να δοκιμάσουμε μικρές σκόπιμες αλλαγές:
- Αν αφαιρέσουμε το
save(), αποτυγχάνουν τόσο το test αποθήκευσης με fake όσο και εκείνο με mock. - Αν αφαιρέσουμε το
send(), αποτυγχάνει το test ειδοποίησης. - Αν αφαιρέσουμε τον έλεγχο
task.completed, αποτυγχάνει το test της επανάληψης.
Αυτή είναι μια χρήσιμη ερώτηση για κάθε test:
Ποιο συγκεκριμένο λάθος στον κώδικα θα έκανε αυτό το test να αποτύχει;
Πότε προτιμάμε κάθε προσέγγιση
| Ανάγκη | Συνήθως κατάλληλη επιλογή | Γιατί |
|---|---|---|
| Απλός υπολογισμός ή domain object | Πραγματική υλοποίηση | Έχει μικρό κόστος και ελέγχουμε τον πραγματικό κώδικα. |
| Αποθήκευση και ανάγνωση μέσα σε ένα σενάριο | Fake | Διατηρεί λειτουργική κατάσταση μεταξύ κλήσεων. |
| Πολλά tests χρειάζονται το ίδιο απλό repository | Κοινό fake | Μειώνει επαναλαμβανόμενο setup. |
| Μία συγκεκριμένη απάντηση από dependency | Stub | Εκφράζει απλά τη συνθήκη του σεναρίου. |
| Έλεγχος ότι ζητήθηκε ειδοποίηση | Mock ή spy | Η αλληλεπίδραση αποτελεί μέρος της απαίτησης. |
| Μεμονωμένη αποτυχία μιας εξάρτησης | Stub ή mock με σφάλμα | Το σενάριο ρυθμίζεται χωρίς πολύπλοκο fake. |
| SQL, mappings, constraints, συναλλαγές | Integration test | Χρειάζεται να εκτελεστεί ο πραγματικός adapter. |
| Ολόκληρη διαδρομή χρήστη | Επιλεγμένο end-to-end test | Ελέγχει τη σύνδεση των επιμέρους τμημάτων. |
Ο πίνακας είναι πρακτικός οδηγός. Η τελική επιλογή εξαρτάται από το συμβόλαιο και το κόστος του test.
Ένα fake έχει αρχικό κόστος κατασκευής και συντήρησης. Συμφέρει περισσότερο όταν η απλή συμπεριφορά του επαναχρησιμοποιείται σε αρκετά σενάρια.
Ένα mock έχει μικρό αρχικό κόστος. Αν όμως κάθε test χρειάζεται μεγάλη λίστα ρυθμίσεων και επαληθεύσεων, το συνολικό κόστος συντήρησης αυξάνεται.
Η Google επισημαίνει ότι η υπερβολική χρήση mocks μπορεί να δυσκολεύει την κατανόηση και το refactoring, επειδή τα tests αρχίζουν να γνωρίζουν πολλές λεπτομέρειες της υλοποίησης. Don’t Overuse Mocks
Πώς συνδέεται αυτό με την Clean Architecture
Στην Clean Architecture οι εξαρτήσεις του πηγαίου κώδικα κατευθύνονται προς τις εσωτερικές πολιτικές και τους κανόνες της εφαρμογής.
Το use case δεν χρειάζεται να γνωρίζει αν η αποθήκευση γίνεται σε PostgreSQL, σε αρχείο ή μέσω κάποιου εξωτερικού API. Χρειάζεται ένα συμβόλαιο που εκφράζει τις δικές του ανάγκες. The Clean Architecture — Robert C. Martin
Μια πιθανή οργάνωση είναι:
domain/
task.py
application/
complete_task.py
ports.py
errors.py
infrastructure/
postgres_task_repository.py
email_completion_notifier.py
tests/
unit/
integration/
Στο άρθρο κρατήσαμε τον κώδικα σε δύο αρχεία για να είναι εύκολος στην εκτέλεση. Δεν χρειάζονται πολλοί φάκελοι για να καταλάβουμε την αρχή.
Οι εξαρτήσεις του πηγαίου κώδικα μπορούν να φαίνονται ως εξής:
CompleteTask ------------> TaskRepository
PostgresTaskRepository -> TaskRepository
InMemoryTaskRepository -> TaskRepository
Το CompleteTask χρησιμοποιεί το port.
Ο πραγματικός adapter και το fake υλοποιούν τη συμπεριφορά που περιγράφει το port. Στην Python αυτή η συμμόρφωση μπορεί να είναι δομική, χωρίς ρητή κληρονομικότητα.
Στην παραγωγή συνδέουμε το use case με τον πραγματικό adapter. Στα unit tests το συνδέουμε με το κατάλληλο double.
Αυτή η σύνδεση μέσω constructor είναι dependency injection. Δεν απαιτεί κάποιο ειδικό framework.
Η Clean Architecture διευκολύνει την επιλογή fake ή mock, επειδή κάνει τα όρια καθαρά. Δεν επιβάλλει μία από τις δύο τεχνικές.
Η σχέση με το SOLID
Το SOLID βοηθά να σχεδιάσουμε συνεργασίες που είναι κατανοητές και μπορούν να αντικατασταθούν με ελεγχόμενο τρόπο.
Single Responsibility Principle
Το Task διαχειρίζεται τη δική του μετάβαση κατάστασης.
Το CompleteTask συντονίζει το use case.
Ο adapter αποθήκευσης διαχειρίζεται την επικοινωνία με την υποδομή.
Αυτό περιορίζει την ανάμειξη διαφορετικών πηγών αλλαγής. Το SRP σχετίζεται με τους λόγους αλλαγής μιας μονάδας, όχι με έναν αυθαίρετο αριθμό μεθόδων ή γραμμών. The Single Responsibility Principle
Open Closed Principle
Μπορούμε να προσθέσουμε έναν adapter που στέλνει ειδοποιήσεις μέσω άλλης υπηρεσίας χωρίς να αλλάξουμε τη λογική ολοκλήρωσης.
Το σημείο σύνθεσης της εφαρμογής θα επιλέξει τη νέα υλοποίηση.
Αυτό δεν σημαίνει ότι πρέπει να προβλέψουμε κάθε μελλοντική αλλαγή. Χρειαζόμαστε χρήσιμα σημεία επέκτασης εκεί όπου υπάρχουν πραγματικά όρια. The Open Closed Principle
Liskov Substitution Principle
Ένα fake πρέπει να μπορεί να αντικαταστήσει τον πραγματικό συνεργάτη μέσα στο συμβόλαιο που ελέγχει.
Αν το πραγματικό repository επιστρέφει None όταν δεν βρίσκει εργασία, ενώ το fake επιστρέφει ένα κενό Task, οι υλοποιήσεις διαφωνούν σημασιολογικά.
Το ίδιο όνομα μεθόδου δεν αρκεί για ασφαλή υποκατάσταση.
Interface Segregation Principle
Το use case χρειάζεται ένα μικρό TaskRepository και έναν CompletionNotifier.
Δεν χρειάζεται ένα τεράστιο interface με διαχείριση χρηστών, migrations, exports και ειδοποιήσεις μαζί.
Ένα στενό συμβόλαιο περιορίζει και τις δυνατότητες που πρέπει να υλοποιήσει το fake.
Dependency Inversion Principle
Το use case εξαρτάται από τα ports που εκφράζουν τις ανάγκες της εφαρμογής.
Οι adapters προσαρμόζονται σε αυτά τα ports.
Η dependency injection είναι ένας τρόπος να συνδέσουμε τις υλοποιήσεις. Το Dependency Inversion αφορά τον σχεδιασμό και την κατεύθυνση των εξαρτήσεων. Design Principles and Design Patterns — Robert C. Martin
Best practices για καθαρά και χρήσιμα tests
Γράφουμε ένα test για μια συγκεκριμένη συμπεριφορά
Ένα test μπορεί να έχει περισσότερα από ένα assertions, όταν όλα τεκμηριώνουν την ίδια συμπεριφορά.
Στο test αποθήκευσης, η επιστρεφόμενη τιμή και η αποθηκευμένη κατάσταση περιγράφουν το ίδιο αποτέλεσμα.
Αν προσθέσουμε στο ίδιο test ειδοποιήσεις, permissions, audit logs και formatting, η αποτυχία γίνεται δυσκολότερο να ερμηνευτεί.
Η αντιστοιχία «μία μέθοδος παραγωγής, ένα test» δεν είναι χρήσιμος γενικός κανόνας. Test Behaviors, Not Methods
Κρατάμε καθαρά τα Arrange Act Assert
Το test πρέπει να επιτρέπει στον αναγνώστη να ξεχωρίζει:
- Ποιες είναι οι αρχικές συνθήκες.
- Ποια ενέργεια εκτελείται.
- Ποιο αποτέλεσμα αναμένεται.
Δεν χρειάζεται κάθε test να έχει σχόλια Arrange, Act, Assert. Οι κενές γραμμές και τα σαφή ονόματα συχνά αρκούν.
Επαληθεύουμε μόνο τις αλληλεπιδράσεις που έχουν σημασία
Αν η απαίτηση είναι να σταλεί μία ειδοποίηση, έχει νόημα να ελέγξουμε τη μία κλήση.
Αν η απαίτηση είναι η τελική κατάσταση, μπορεί να μη χρειάζεται να ελέγξουμε πόσες φορές έγινε ανάγνωση από το repository.
Κάθε επαλήθευση κλήσης προσθέτει έναν περιορισμό.
Πριν τη γράψουμε, εξετάζουμε αν μια διαφορετική ακολουθία κλήσεων, με το ίδιο επιτρεπτό αποτέλεσμα, θα ήταν επίσης σωστή.
Δεν αντικαθιστούμε με mock τη συμπεριφορά που εξετάζουμε
Αν θέλουμε να ελέγξουμε το CompleteTask, εκτελούμε το πραγματικό CompleteTask.
Αν αντικαταστήσουμε τη δική του execute() με προκαθορισμένη απάντηση, δεν ελέγχουμε την υλοποίησή του.
Αντίστοιχα, αποφεύγουμε να κάνουμε mock απλά αντικείμενα δεδομένων όπως το Task. Ένα πραγματικό Task είναι εύκολο να δημιουργηθεί και δείχνει καθαρά το σενάριο.
Κάθε test ξεκινά με δική του κατάσταση
Στα παραδείγματα δημιουργούμε νέο repository και νέο notifier για κάθε test.
Έτσι αποφεύγουμε:
- Δεδομένα που διαρρέουν μεταξύ tests.
- Παλιές καταγραφές κλήσεων.
- Εξάρτηση από τη σειρά εκτέλεσης.
Το γεγονός ότι ένα fake είναι γρήγορο δεν σημαίνει ότι πρέπει να μοιράζεται ως mutable global αντικείμενο.
Τα tests παραμένουν απλά
Προτιμάμε σαφείς τιμές εισόδου και σαφή αναμενόμενα αποτελέσματα.
Αποφεύγουμε να υπολογίζουμε το αναμενόμενο αποτέλεσμα αντιγράφοντας τον ίδιο αλγόριθμο με τον κώδικα παραγωγής. Θα μπορούσαμε να αντιγράψουμε και το ίδιο λάθος.
Επίσης, λίγη επανάληψη στο setup μπορεί να είναι προτιμότερη από ένα γενικό helper με πολλές παραμέτρους και κρυφές προεπιλογές. Don’t Put Logic in Tests
Δεν αφήνουμε το fake να γίνει δεύτερη βάση δεδομένων
Το fake υλοποιεί το απαραίτητο συμβόλαιο.
Αν αρχίσουμε να γράφουμε query planner, μηχανισμό συναλλαγών και περίπλοκη προσομοίωση locking, το κόστος του μπορεί να ξεπεράσει την αξία του.
Για συμπεριφορές που εξαρτώνται ουσιαστικά από τη συγκεκριμένη βάση, χρησιμοποιούμε την πραγματική τεχνολογία σε integration tests.
Κρατάμε τα εξωτερικά SDKs πίσω από χρήσιμα όρια
Αν το use case χρησιμοποιεί βαθιές αλυσίδες από αντικείμενα ενός SDK, τα tests μπορεί να χρειάζονται αντίστοιχες αλυσίδες mocks.
Ένα μικρό port, όπως το CompletionNotifier, μπορεί να περιορίσει αυτή τη γνώση στον adapter.
Ο adapter χρειάζεται δικούς του ελέγχους απέναντι στην πραγματική βιβλιοθήκη ή στο κατάλληλο περιβάλλον δοκιμών. Η σύσταση να αποφεύγουμε mocks για τύπους που δεν ελέγχουμε περιγράφεται και στο Google Testing Blog. Don’t Mock Types You Don’t Own
Ελέγχουμε τις αποτυχίες και τα όρια
Το happy path είναι μόνο ένα μέρος της συμπεριφοράς.
Στο παράδειγμά μας ελέγχουμε επίσης:
- Απουσία εργασίας.
- Αποτυχία αποθήκευσης.
- Επανάληψη ολοκλήρωσης.
Σε άλλα use cases μπορεί να έχουν σημασία κενές είσοδοι, όρια ποσότητας, ληγμένες προθεσμίες ή ακυρωμένες ενέργειες.
Αν η συμπεριφορά εξαρτάται από χρόνο, τυχαιότητα ή δίκτυο, χρειαζόμαστε τρόπο να ελέγχουμε αυτές τις συνθήκες με προβλέψιμες εισόδους.
Χρησιμοποιούμε το coverage ως ένδειξη
Το coverage δείχνει ποιος κώδικας εκτελέστηκε. Δεν αποδεικνύει ότι τα assertions ελέγχουν σωστά τις απαιτήσεις.
Ένα test μπορεί να εκτελεί όλο το use case και να ελέγχει μόνο ότι η επιστρεφόμενη τιμή δεν είναι None.
Η δοκιμή με μικρά σκόπιμα λάθη, όπως η αφαίρεση της αποθήκευσης, βοηθά να αξιολογήσουμε αν το test προστατεύει πράγματι τη συμπεριφορά που δηλώνει.
Τι δεν αποδεικνύουν τα unit tests του παραδείγματος
Το fake και το mock μάς βοηθούν να ελέγξουμε τη λογική του use case.
Για την πραγματική εφαρμογή χρειάζονται επιπλέον αποδείξεις:
| Επίπεδο | Τι θέλουμε να αποδείξουμε |
|---|---|
| Unit tests | Οι κανόνες και οι αποφάσεις του use case είναι σωστοί. |
| Κοινά contract tests | Fake και πραγματικός adapter συμφωνούν στο συμβόλαιο. |
| Integration tests | Ο adapter συνεργάζεται σωστά με βάση, SDK ή υπηρεσία. |
| Επιλεγμένα end-to-end tests | Μια κρίσιμη διαδρομή λειτουργεί μέσα στην εφαρμογή. |
Υπάρχει επίσης ένα συγκεκριμένο όριο στον κώδικά μας.
Αποθηκεύουμε πρώτα την ολοκλήρωση και μετά ζητάμε ειδοποίηση. Αν η αποθήκευση πετύχει αλλά η ειδοποίηση αποτύχει, μια επόμενη εκτέλεση θα βρει ήδη ολοκληρωμένη την εργασία.
Η σημερινή υλοποίηση δεν εγγυάται αξιόπιστη παράδοση της ειδοποίησης σε αυτό το σενάριο. Ούτε ο διαδοχικός έλεγχος επανάληψης αποδεικνύει προστασία απέναντι σε ταυτόχρονες κλήσεις.
Αν απαιτείται αξιόπιστη σύνδεση αποθήκευσης και δημοσίευσης μηνύματος, μια επιλογή είναι το transactional outbox: αποθηκεύουμε την αλλαγή και την πρόθεση αποστολής στην ίδια συναλλαγή και επεξεργαζόμαστε την αποστολή ξεχωριστά. Χρειάζεται επίσης αντιμετώπιση πιθανών διπλών παραδόσεων. Transactional Outbox Pattern — AWS
Αυτό θα ήταν πρόσθετη απαίτηση της εφαρμογής, με δική της σχεδίαση και tests.
Πώς αποφασίζουμε στην πράξη
Πριν δημιουργήσουμε ένα test double, μπορούμε να ακολουθήσουμε αυτή τη σειρά σκέψης:
- Ποια συμπεριφορά θέλω να αποδείξω; Διατυπώνω την απαίτηση πριν επιλέξω τεχνική.
- Μπορώ να χρησιμοποιήσω την πραγματική υλοποίηση; Αν είναι απλή, γρήγορη και προβλέψιμη, τη χρησιμοποιώ.
- Χρειάζομαι λειτουργική κατάσταση μεταξύ κλήσεων; Ένα μικρό fake συχνά βοηθά.
- Χρειάζομαι μόνο μία συγκεκριμένη απάντηση ή αποτυχία; Ένα stub μπορεί να αρκεί.
- Η κλήση προς έναν συνεργάτη είναι μέρος της απαίτησης; Χρησιμοποιώ mock ή spy και ελέγχω τις σχετικές λεπτομέρειες.
- Η ορθότητα εξαρτάται από την πραγματική υποδομή; Προσθέτω integration test στο αντίστοιχο όριο.
Για το δικό μας παράδειγμα, το fake repository είναι μια καλή προεπιλογή για ελέγχους κατάστασης και επαναλαμβανόμενων ενεργειών. Το mock notifier είναι χρήσιμο όταν θέλουμε να επαληθεύσουμε την απαίτηση αποστολής. Ένα stub αποτυχίας διευκολύνει τον έλεγχο σφαλμάτων.
Ένα χρήσιμο test εκφράζει καθαρά την απαίτηση, εντοπίζει ουσιαστικά λάθη και επιτρέπει να αλλάζουμε την εσωτερική οργάνωση του κώδικα όσο διατηρούμε το συμφωνημένο συμβόλαιο.
Top comments (0)