zynif Δημοσ. 16 Νοεμβρίου 2017 Δημοσ. 16 Νοεμβρίου 2017 Καλησπέρα. Θέλω λιγο την βοήθεια σας. Εστω λοιπον οτι έχουμε ενα eshop. Μετα απο καποια αγορά προϊοντος ενδέχεται να δημιουργήσουμε ένα εκτπωτικό κουπόνι, για επόμενη αγορά , το οποιο ομως εχει ημερομηνια λήξης. Θελουμε έστω 5 ή 2 μέρες πριν την λήξη του κουπονιου να στλένουμε ενα mail στο πελάτη και να τον ενημερωνουμε οτι προκεται να ληξει το κουπονι. Αυτο πρέπει να γινει με cron job. Το θέμα ειναι το εξης. Πως στο unit test θα προσομοιώσουμε το περασμα του χρόνου; Θέλουμε να φτιάξουμε ενα Unit test, oπου θα εξετάσουμε οτι ο πελάτης έλαβε τον σωστο αριθμο των ενημερωτικών mails Υπάρχει κάτι καλύτερο απο ένα for loop σαν το παρακάτω ; <?php require_once './path/to/CouponReminder.php'; require_once 'MyFakeClock.php'; class CouponReminderTests extends PHPUnit_Framework_TestCase { public function testAllShouldBeNotified() { $start = '2017-08-15 12:00:00'; $clocky = new MyFakeClock($start); $MockRemind = \Mockery::mock('CouponReminder[sendWarningMail]'); $MockRemind->shouldReceive('sendWarningMail')->andReturn(); $interval = 3600; // in seconds cron job will run every hour // tick 2 months $timeLimit = 2 * 30 * 24 * 3600; $MockRemind->setMockClock($clocky); for ($step=0 ; $step < $timeLimit; $step+=$interval) { $MockRemind->getNonExpired(); $MockRemind->getMockClock()->tick($interval); } $MockRemind->shouldReceive('sendWarningMail')->times(2); } } ?> Το MockClock δεν κανει override καποια συναρτηση της php απλα δίνει σε string την mocked date. Ευχαριστώ
defacer Δημοσ. 16 Νοεμβρίου 2017 Δημοσ. 16 Νοεμβρίου 2017 Νομίζω ότι έχεις γράψει πολλά που είναι πέραν της ουσίας (και δεν καταλαβαίνω και ακριβώς γιατί έχεις loop εκεί ούτε πώς λειτουργούν πράγματα όπως το ->setMockClock. Πού είναι αυτή η method και για ποιό λόγο υπάρχει καν?!?!? Η ουσία είναι απλή. Έχεις ένα κομμάτι κώδικα που κοιτάει τι ώρα είναι αν είναι κατάλληλη ώρα εκτελεί μια ενέργεια Προσοχή, δε μιλάω για το cronjob το ίδιο. Μιλάω για μια class που θα έχεις για να κάνει αυτή τη δουλειά, το αν θα καταλήγεις εκεί απο cron ή οτιδήποτε άλλο (πρέπει να) είναι αδιάφορο. Η class αυτή λοιπόν έχει ένα dependency σε κάτι που λέει "τι ώρα είναι". Θα πάρεις αυτή τη dependency μέσα στον constructor και είσαι άρχοντας, στο unit test απλά θα δώσεις ένα mock που επιστρέφει όποια ώρα θες εσύ. Ήτοι interface ICurrentTimeProvider // αυτό θα είναι το dependency που θα έχεις { public function getCurrentDateTime() : DateTimeImmutable; } final class HostSystemClockTimeProvider implements ICurrentDateTimeProvider { public function getCurrentDateTime() : DateTimeImmutable { return new DateTimeImmutable(); } } ΥΓ δε θέλω να βλέπω require, 2017 είναι, ο Composer κάνει autoloader μόνος του και ακόμα κι αν δεν τον θέλεις για τίποτα άλλο.
zynif Δημοσ. 17 Νοεμβρίου 2017 Μέλος Δημοσ. 17 Νοεμβρίου 2017 Λοιπόν το MockClock κάνει το παρακάτω <?php class MyFakeClock { private $unixTime; public function __construct($timestamp) { $this->unixTime = strtotime( $timestamp); } public function getTime() { return date("Y-m-d H:i:s",$this->unixTime); } public function tick($seconds) { $this->unixTime += $seconds; } } ?> Τωρα μέσα στην μέθοδο getNonExpired(); έχω κατι σαν $currentNow = is_null($this->mockClock) ? ' NOW() ' : "'".$this->mockClock->getTime()."'"; το οποίο το περνάω μεσα σ ενα SQL query. Βασικά θέλω να πετύχω κάτι σαν το παρακάτω παράδειγμα (το οποίο ειναι Javascript) beforeEach(function() { timerCallback = jasmine.createSpy("timerCallback"); jasmine.clock().install(); }); afterEach(function() { jasmine.clock().uninstall(); }); it("causes an interval to be called synchronously", function() { setInterval(function() { timerCallback(); }, 100); expect(timerCallback).not.toHaveBeenCalled(); jasmine.clock().tick(101); expect(timerCallback.calls.count()).toEqual(1); jasmine.clock().tick(50); expect(timerCallback.calls.count()).toEqual(1); jasmine.clock().tick(50); expect(timerCallback.calls.count()).toEqual(2); }); }); οτι δηλαδή αν ξεκινήσω το mock ρολοι στις 2017-08-15 12:00:00 και το προχωρήσω 3 μήνες οτι θα γίνουν οι σωστες ενέργειες. πχ θα εχει κληθεί Χ φορες η getNonExpired
defacer Δημοσ. 17 Νοεμβρίου 2017 Δημοσ. 17 Νοεμβρίου 2017 Λοιπόν, καταρχήν ξεχνάς τα πάντα όλα που έχουν να κάνουν με το JS παράδειγμα που βλέπουμε. Η όλη φάση με το jasmine.clock γίνεται όπως γίνεται επειδή ο σκοπός της ύπαρξής του είναι να σε βοηθήσει να τεστάρεις κώδικα ο οποίος δεν είναι φτιαγμένος για να τεστάρεται, και οι λεπτομέρειες έχουν να κάνουν με τις πολύ συγκεκριμένες ιδιαιτερότητες του περιβάλλοντος (και ακόμα πιο συγκεκριμένα της setTimeout).Οπότε: αυτό που βλέπεις είναι ένα design φτιαγμένο έτσι για να εξυπηρετεί μια πολύ συγκεκριμένη περίπτωση, η οποία επιπλέον έχει να κάνει με testing πραγμάτων που είναι "κακώς" γραμμένα εξαρχής (κάνουν απευθείας χρήση της setTimeout). Μεταφέροντας αυτή την προσέγγιση σε μια τελείως διαφορετική κατάσταση με καρμπόν απλά δημιουργείς κώδικα που δεν ξέρει τι του γίνεται και το μόνο που θα καταφέρει είναι να σε μπερδέψει. Ξέχνα τα όλα και ξεκίνα από λευκή κόλλα.Τώρα, επειδή βλέπω είσαι σε φάση που χρειάζεσαι λίγο πρόλογο παραπάνω... θα το πάω λίγο με σωκρατική μέθοδο γιατι δεν έχω χρόνο για σεντόνι με τη μία. Εγώ θα κάνω ερωτήσεις "γιατί μπαμπά", εσύ σκέψου ποιά είναι η απάντηση. Spoiler, δεν υπάρχει ικανοποιητική απάντηση στις ερωτήσεις που θα κάνω. Η απάντηση είναι πως τα πράγματα θα πρέπει να γίνουν αλλιώς, αλλά η ουσία είναι να καταλάβεις μόνος σου το γιατί. Ξεκίνα διαβάζοντας το depenency injection demystified -- είναι πολύ μικρό αλλά πολύ πολύ βασικό. Θα κάνουμε dependency injection (DI), χωρίς DI η συγγραφή tests περνάει σε επίπεδο απόλαυσης fuck my life.Για ποιό λόγο λοιπόν έχεις αυτό τον κώδικα που έδειξες $currentNow = is_null($this->mockClock) ? ' NOW() ' : "'".$this->mockClock->getTime()."'"; να περιέχει conditional? Γιατί δηλαδή να μην είναι απλώς $currentNow = "'".$this->mockClock->getTime()."'"; ? ΥΓ από το πώς φτιάχνεις το $currentNow φαίνεται ότι το πετάς μέσα σε sql όπως είναι. Ποτέ μην το κάνεις αυτό, ή τουλάχιστον μόνο αφού περάσουν πάνω από το πτώμα σου. The road to hell is paved with good intentions. Δε θα είσαι πρακτικά ευάλωτος σε sql injection εδώ, αλλά just don't do it.
zynif Δημοσ. 17 Νοεμβρίου 2017 Μέλος Δημοσ. 17 Νοεμβρίου 2017 Ε βασικα στον test Κωδικα όπως βλέπεις έχω στον τεστ κώδικα το $MockRemind->setMockClock($clocky); στο production κωδικα δεν υπάρχει αυτο το statement άρα μεσα στο SQL query υπάρχει το NOW(). Χρησιμοποιω την DATEDIFF για να βρω ποσες μέρες μενουν μεχρι την λήξη του κουπονιου. Εν πάσει περιπτώσει Το cron job θα τρέχει καθε μια ωρα και ας πουμε οτι εχει την παραπάτω μορφή <?php $coupon = new CouponReminder(); $coupon->getNonExpired(); ?> θέλω μεσα στο test να θέσω τον χρόνο να ξεκινά πχ απο τις 2017-10-01 00:00:00 εως τις 2017-12-31 00:00:00 και θα πρέπει το $coupon->getNonExpired(); να εκτελείται καθε μια ωρα . Για αυτο εβαλα και το for loop που βλέπεις στο αρχικο Post παιρνοντας ως παράδειγμα την Javascript
defacer Δημοσ. 17 Νοεμβρίου 2017 Δημοσ. 17 Νοεμβρίου 2017 Ε βασικα στον test Κωδικα όπως βλέπεις έχω στον τεστ κώδικα το $MockRemind->setMockClock($clocky); στο production κωδικα δεν υπάρχει αυτο το statement άρα μεσα στο SQL query υπάρχει το NOW(). Χρησιμοποιω την DATEDIFF για να βρω ποσες μέρες μενουν μεχρι την λήξη του κουπονιου. Δε ρωτάω πώς δουλεύει ο κώδικας, τον έδειξες και το κατάλαβα. Ρωτάω γιατί δουλεύει έτσι και όχι κάπως αλλιώς. Πρέπει να υπάρχει κάποιος λόγος που κάνει ένα conditional branch εκεί, σωστά; Δε γράφουμε if για την πλάκα μας. Ξαναλέω ότι εν τέλει δεν υπάρχει τέτοιος λόγος και δε θα έπρεπε να υπάρχει condition και εν τέλει θα έπρεπε πάντα να δουλεύεις με κάτι όπως το mock clock που έχεις τώρα (αν και τα "μηχανικά μέρη" δεν πρέπει να είναι έτσι για λόγους που θα δούμε αργότερα, η ιδέα πάντως είναι αυτή). Αλλά για να προχωρήσεις μπροστά με σιγουριά πρέπει πρώτα να το νιώσεις μόνος σου αυτό. Ο τρόπος να το νιώσεις μόνος σου είναι να προσπαθήσεις να το βάλεις σε λέξεις και να διαπιστώσεις πως είτε δε μπορείς είτε υπάρχουν αντεπιχειρήματα. Ο σκοπός εδώ είναι να δεις την κατάσταση με άλλο μάτι. Το να σου πω εγώ "κάνε ΧΥΖ" δεν είναι λύση, το έκανα περιληπτικά στην πρώτη μου απάντηση και εμφανώς δεν έπιασε γιατί σου λείπουν προαπαιτούμενες σκέψεις. Ο σκοπός λοιπόν είναι να σε κάνουμε να τις σκεφτείς. ;-) και θα πρέπει το $coupon->getNonExpired(); να εκτελείται καθε μια ωρα. Για αυτο εβαλα και το for loop που βλέπεις στο αρχικο Post παιρνοντας ως παράδειγμα την Javascript Σκέψου το ξανά αυτό. Θέλουμε να κάνουμε unit test μια function whatever(). Έχει απολύτως καμία σχέση το πώς θα γίνει το unit test με το αν στον production κώδικα η whatever() καλείται μία φορά, ή 100 φορές μέσα σε loop, ή είναι μέσα σε ένα if που εκτελείται μόνο κάθε πρώτη Τετάρτη τα δίσεκτα έτη; Όχι. Επομένως βγάλε τελείως από το μυαλό σου οτιδήποτε έχει να κάνει με το ότι το cron θα τρέχει κάθε Χ ώρες, απλά σε οδηγεί σε λάθος δρόμο. Αυτά έχουν να κάνουν με το cron, εδώ κάνουμε unit test το CouponReminder που είναι τελείως άλλο πράγμα. Επίσης, και έχε το αυτό υπόψη για πάντα στο μέλλον: θέλουμε να κάνουμε "unit test" στο CouponReminder. Αν μέσα στο τέστ υπάρχει οποιαδήποτε συσχέτιση με οτιδήποτε που είναι εξωτερικό του CouponReminder (όπως το cron), τότε δε γράφεις unit test.
zynif Δημοσ. 17 Νοεμβρίου 2017 Μέλος Δημοσ. 17 Νοεμβρίου 2017 Λοιπον , ακομα δεν εχω διαβάσει το Link που έστειλες για το dependency injection ,αλλα αυτο που εχω καταλάβει απο videos και blogs ειναι οτι εξωτερικά components πχ κλήση σε ενα 3rd party API θα πρέπει να ειναι loosely coupled απο τον κυριο κωδικα. Ετσι πχ θα στα tests μπορεις κανεις Mock το 3rd party API για να μην κάνεις πραγματικα calls ή να γράψεις εσυ το response . Τωρα ψάχνοντας στο internet για mock time σε PHP βρήκα κάποια πράγματα , τα οποία είτε δεν πολυκαταλάβαινα πως λειτουργούν είτε δεν ήμουν σίγουρος κατα ποσο αξιοπιστα ήταν. Οποτε έφτιαξα στην κλαση CouponReminder μια property mockClock. Αν αυτή δεν ειναι null, τοτε στο SQL στέλνω την τιμή NOW() αλλιως την τιμή που εχει το MockClock. public function getCurrentDateTime() : DateTimeImmutable; H άνω κάτω τελεία ειναι php 7 feature και σημαίνει οτι η getCurrentDateTime() θα επιστρέφει μεταβλητή τύπου DateTimeImmutable; PS : Ποια η διαφορά integration testing vs unit testing ?
defacer Δημοσ. 17 Νοεμβρίου 2017 Δημοσ. 17 Νοεμβρίου 2017 DI: "ναι" αυτό που θες να πεις είναι, αλλά δεν το λες πολύ καλά, δεν το έχεις χωνέψει. Διάβασε το άρθρο και μιλάμε μετά να μη γράφω τα ίδια. Η άνω κάτω τελεία ναι, είναι return type declaration. Τώρα όσον αφορά την CouponReminder. Αυτή έχει (όχι μόνο ένα βέβαια αλλά εδώ αυτό συζητάμε) dependency. Χρειάζεται κάπως να πει στη database "από τάδε ώρα και μετά", αλλά δεν ξέρει ποιά ώρα πρέπει να πει. Οπότε υπάρχει dependency σε "κάτι που λέει την ώρα". Ακόμα και το να πεις "NOW()" δε σημαίνει ότι δεν έχεις dependency. Σημαίνει απλά ότι πετάς το μπαλάκι αλλού. Και αυτό ακριβώς είναι που πρέπει να κάνεις: να πετάξεις το μπαλάκι αλλού. Αντί να αποφασίζει μόνη της η CouponReminder (δηλαδή, το dependency να καλύπτεται εσωτερικά στην υλοποίησή της, όπως γινόταν πριν βάλεις τη setMockClock) τότε όπως διαπίστωσες υπάρχει πρόβλημα τουλάχιστον στο testing γιατί εκεί πρέπει να πάρεις εσύ όλες τις αποφάσεις εκτός από τη μία πάνω στην οποία θα τεστάρεις. Πράγμα που δε μπορείς να κάνεις* αν οι αποφάσεις είναι hardcoded σε κάποια method της CouponReminder. Στην πρώτη μου απάντηση λοιπόν αναγνωρίζω ότι φαίνεται να έχεις ένα dependency στο "τι ώρα είναι τώρα"**, και αποφασίζω ότι αυτό είναι δουλειά για κάποιον που βάφτισα ICurrentTimeProvider. Αυτός θα κάνει μια δουλειά και μόνο μια δουλειά. Όταν το CouponReminder θέλει να αναφερθεί στο "τώρα", δε θα αποφασίζει μόνο του πότε είναι αυτό αλλά θα λέει στον ICurrentTimeProvider "εσένα γι' αυτό σε φέραμε, λέγε". Πάντα, χωρίς κανένα condition. Αυτό λοιπόν που τώρα λες "mock clock" θα προβιβαστεί σε "current time provider" και θα χρησιμοποιείται πάντα, αντικατοπτρίζοντας το γεγονός πως η ανάγκη να ορίσουμε τι σημαίνει τώρα είναι πάγια ανάγκη του CouponReminder. Το NOW() θα φύγει από τον κώδικα. Εδώ έρχεται το DI και λέει ότι αυτός ο ICurrentTimeProvider θα πρέπει να περαστεί έτοιμος σαν παράμετρος στον constructor του CouponReminder, και όχι μέσω setWhatever όπως γίνεται τώρα με το mock clock. Αυτό θα σου επιτρέψει και να εξαλείψεις το conditional, μιας και θα είναι 100% σίγουρο και υποχρεωτικό το να υπάρχει time provider. Καλά ως εδώ; Πόσταρε μια αλλαγμένη version στην παραπάνω κατεύθυνση. * Πολλές φορές υπάρχουν τρόποι και workarounds για να το κάνεις παρόλα αυτά. Το θέμα είναι ότι αυτά είναι workarounds και τα κάνεις όταν είσαι αναγκασμένος, όχι ο τρόπος με τον οποίο πρέπει να γίνονται τα πράγματα γενικά. ** Πιθανότατα το dependency σου δεν είναι αυτό και θα έπρεπε να γίνει λίγο διαφορετικά, αλλά ας μην εκτροχιαστούμε, σαν παράδειγμα μας υπερκαλύπτει κι έτσι. 1
zynif Δημοσ. 17 Νοεμβρίου 2017 Μέλος Δημοσ. 17 Νοεμβρίου 2017 Επίσης ένα είδος dependency ειναι και το sendMail, Δεν θέλω στα τεστ να στέλνεται mail. Απλα δεν εχω κατσει να διαβαζω το documentation του Mockery. Για αυτο περασα το mockClock με set μεσα στον constructor. Λοιπον τώρα ο κώδικας έχει ως εξής <?php class CouponReminder { private $clockProvider; private $db; public function __construct($clockprovider, $db) { $this->clockProvider = $clockprovider; $this->db = $db; } private function sendMail() { } public function getNotExpired() { $query = "SELECT email, DATEDIFF(expires_at , '".$this->clockProvider->getCurrentDateTime()->format("Y-m-d H:i:s")."') AS diff FROM coupons "; $result = mysqli_query($this->db, $query); while ($row = mysqli_fetch_object($result)) { if ($row->diff == 30) { $this->sendMail($row['email']); } } } } <?php require_once './HostSystemClockTimeProvider.php'; require_once './CouponReminder.php'; class CouponReminderTests extends PHPUnit\Framework\TestCase { public function testCoupon() { $con = mysqli_connect("localhost","root","","insomnia"); $clockProvider = new HostSystemClockTimeProvider(); $coup = new CouponReminder($clockProvider, $con); $coup->getNotExpired(); } }
defacer Δημοσ. 17 Νοεμβρίου 2017 Δημοσ. 17 Νοεμβρίου 2017 Cool, τα πράγματα πάνε προς τη σωστή κατεύθυνση. Τώρα: Έχεις dependency σε κάτι του στυλ "mail service". Φτιάξε ένα κατάλληλο interface για αυτό το υποτιθέμενο service και κάνε το ίδιο που έγινε με τον time provider. Φτιάξε πιο σωστά τον κώδικα. Single responsibility principle. Η getNotExpired() λέει ψέματα γιατί δεν κάνει αυτό που λέει το όνομά της, αλλιώς θα έπρεπε να λέγεται getNotExpiredAndSendEmail() και τώρα πια είναι φανερό ότι καταπατά την SRP και αυτό θα δημιουργήσει προβλήματα. Κάποιος θα καλέσει την getExpired(), θα πάρει από αυτή κάτι σαν αποτέλεσμα, θα μετατρέψει αυτό το αποτέλεσμα σε έτοιμη μπουκιά "στείλε τάδε τάδε μειλ με τάδε τάδε περιεχόμενα", και εν τέλει θα στείλει τα email. Αυτός ο κάποιος μπορεί (και συνήθως πρέπει) να είναι διαφορετικός κάποιος σε κάθε βήμα. Τώρα, συνεχίζω να έχω στο μυαλό μου το πώς θα κάνεις unit test αλλά θα πάρω ένα πλάγιο δρόμο για να φτάσω εκεί, θα φανεί γιατί στο τέλος. Το πρόβλημα τώρα είναι: το dependency που έχεις στη db είναι στο λάθος abstraction level. Όταν είσαι ο CouponReminder, αυτό που θέλεις να κάνεις με το dependency σου είναι κάτι του στυλ "εγώ θα σου πω μια χρονική στιγμή και συ θα μου πεις ποιά κουπόνια θα είναι ληγμένα τότε". Δεν θέλεις να έχεις dependency που ο διάλογος θα πάει "εγώ θα σου δώσω ένα sql query και εσύ θα μου επιστρέψεις ένα μάτσο πίνακες και κάπως θα βγάλουμε άκρη". Αυτό που κάνεις τώρα το κάνεις όχι γιατί θα το ήθελες αλλά επειδή δεν έχεις στο μυαλό σου την εικόνα του τι θα ήταν καλύτερο. Φαντάσου λοιπόν να περνάς το db dependency σε 100 classes στο πρόγραμμά σου. Δεν έχεις ιδέα τι κάνει η καθεμία από αυτές με τη db (ποιούς πίνακες χρησιμοποιεί, κλπ). Αν αλλάξεις κάτι στη db (π.χ. τι είναι καταρχήν) θα πρέπει να πας να αλλάξεις 100 classes αντίστοιχα. Αν αλλάξεις κάτι στη δομή των δεδομένων σου (π.χ. το όνομα ή τον τύπο ενός column κάπου) θα πρέπει να ψάξεις μέσα σε 100 classes και σε όλα τους τα dependencies μπας και κάπου εκεί υπάρχει κάποια sql που θα χαλάσει με την αλλαγή που έκανες. Δε γίνεται δουλειά έτσι, γι' αυτό πρέπει να βάζεις "διαδρόμους πυροπροστασίας" εκεί που πρέπει. Αντί λοιπόν να έχουμε CouponReminder <-- mysqli, κάνε τα πράγματα έτσι που να έχουμε CouponReminder <-- ICouponRepository <-- mysqli. Για παράδειγμα θα μπορούσε να είναι κάτι του στυλ (αυτό δεν έχει τόση σημασία γιατί μπορείς πολύ εύκολα να το αλλάξεις όταν τα πράγματα είναι σωστά φτιαγμένα) interface ICouponRepository { public function getExpiredCoupons(DateTimeInterface $expiration) : array; } 1
zynif Δημοσ. 17 Νοεμβρίου 2017 Μέλος Δημοσ. 17 Νοεμβρίου 2017 Αχ ας μην βαλουμε repository γιατι θ αρχίσω να το χάνω. Στην ουσια δηλαδή μου λες οτι η βαση ειναι και αυτή ενα dependency ( και ειτε ειναι η mysql production database είτε η development database ειτε ακόμα και μια Mongodb); Εφτιαξα αυτα λοιπον <?php class CouponReminder { private $clockProvider; private $mailProvilder; private $db; public function __construct($clockprovider, $mailer,$db) { $this->clockProvider = $clockprovider; $this->mailProvilder = $mailer; $this->db = $db; } public function notify($aboutToExpire) { foreach ($aboutToExpire as $exp) { $this->sendMail($exp); } } private function sendMail($args) { $this->mailProvilder->sendEmail(array('email' => $args->email, 'body' => 'Coupon '.$args->coupon_code.' is about to expire')); } public function getNotExpired() { $aboutToExpireCoupons = array(); $query = "SELECT coupon_code, email, DATEDIFF(expires_at , '".$this->clockProvider->getCurrentDateTime()->format("Y-m-d H:i:s")."') AS diff FROM coupons "; $result = mysqli_query($this->db, $query); while ($row = mysqli_fetch_object($result)) { if ($row->diff == 30) { array_push($aboutToExpireCoupons, $row); } } return $aboutToExpireCoupons; } } τα Interface providers <?php interface ICurrentDateTimeProvider { public function getCurrentDateTime() : DateTimeImmutable; } interface MailProvider { public function sendEmail($args); } και ενα πρόχειρο mailer object <?php require_once './Providers.php'; final class MailerObject implements MailProvider { public function sendEmail($args) { $mail = new PHPMailer(); // ... $mail->addAddress($args['email']); $mail->Body($args['body']); $mail->send(); } }
defacer Δημοσ. 17 Νοεμβρίου 2017 Δημοσ. 17 Νοεμβρίου 2017 Δε γίνεται να κάνεις αυτό το τεστ να είναι καθώς πρέπει unit test χωρίς να βάλεις κάτι εκεί που θα είναι το CouponRepository επειδή έχεις ένα dependency από τα τρία που δε μπορείς να κάνεις mock. Ή για να το πω ισοδύναμα, έχεις κάνει hardcode την κλήση στην π.χ. mysqli_query και δεν υπάρχει τρόπος να γλιτώσεις από τις συνέπειες αυτής της απόφασης παραπάνω από όσο θα μπορούσες να γλιτώσεις από το hardcode NOW(). Δηλαδή υπάρχει, αλλά δε μαθαίνουμε πως να κάνεις ανάποδο τιμόνι αν σου φύγει το αμάξι πριν πάρουμε δίπλωμα με πρώτη-δευτέρα. ;-)
zynif Δημοσ. 17 Νοεμβρίου 2017 Μέλος Δημοσ. 17 Νοεμβρίου 2017 Τώρα ειναι καλύτερα ή θελει κι άλλο βελτίωση ο database provider ? (πχ να παίρνει dependency to connection) interface CouponRepository { public function getExpiredCoupons($now); } <?php require_once './Providers.php'; final class Database implements CouponRepository { private $db; public function __construct($args) { $this->db = mysqli_connect($args['host'],$args['user'], $args['password'],$args['dbname']); } public function getExpiredCoupons($now) { $aboutToExpireCoupons = array(); $query = "SELECT coupon_code, email, DATEDIFF(expires_at , '".$now."') AS diff FROM coupons "; $result = mysqli_query($this->db, $query); while ($row = mysqli_fetch_object($result)) { if ($row->diff == 30) { array_push($aboutToExpireCoupons, $row); } } return $aboutToExpireCoupons; } } <?php class CouponReminder { private $clockProvider; private $mailProvilder; private $dbProvider; public function __construct($clockprovider, $mailer,$db) { $this->clockProvider = $clockprovider; $this->mailProvilder = $mailer; $this->dbProvider = $db; } public function notify($aboutToExpire) { foreach ($aboutToExpire as $exp) { $this->sendMail($exp); } } private function sendMail($args) { $this->mailProvilder->sendEmail(array('email' => $args->email, 'body' => 'Coupon '.$args->coupon_code.' is about to expire')); } public function getNotExpired() { return $this->dbProvider-> getExpiredCoupons($this->clockProvider->getCurrentDateTime()->format("Y-m-d H:i:s")); } } <?php require_once './HostSystemClockTimeProvider.php'; require_once './MailerObject.php'; require_once './Database.php'; require_once './CouponReminder.php'; class CouponReminderTests extends PHPUnit\Framework\TestCase { public function testCoupon() { $db = new Database(array('host' =>'localhost', 'user' => 'root', 'password' => '', 'dbname' => 'insomnia')); $clockProvider = new HostSystemClockTimeProvider(); $mailProvider= new MailerObject(); $coup = new CouponReminder($clockProvider, $mailProvider, $db); $coup->getNotExpired(); } }
zynif Δημοσ. 18 Νοεμβρίου 2017 Μέλος Δημοσ. 18 Νοεμβρίου 2017 Και μήπως το query πρέπει να γίνει SELECT * FROM `coupons` WHERE expires_at >='".$this->clockProvider->getStartDate()."' 00:00:00' AND expires_at <= '".$this->clockProvider->getEndDate()."' 23:59:59'
defacer Δημοσ. 18 Νοεμβρίου 2017 Δημοσ. 18 Νοεμβρίου 2017 Τέλειο είναι, δε δίνω καθόλου σημασία στις λεπτομέρειες (αν θες μπορώ να σου πω μετά), η ουσία στο θέμα που απασχολεί είναι αυτό ακριβώς που έκανες. Τώρα, κάνουμε ένα βήμα πίσω κοιτώντας τι έχουμε, to catch our breath and think. Και μ' αυτή την εικόνα στο νου συζητάμε για mocks. --- Unit test σημαίνει, ο κώδικας που πρόκειται να τεστάρουμε βρίσκεται μόνο μέσα στην CouponReminder. Οποιοσδήποτε εξωτερικός κώδικας θα είναι απόλυτα ελεγχόμενος από εμάς κατά τη διάρκεια του test. Ο λόγος που τώρα πια μπορούμε να το κάνουμε αυτό, είναι ότι μέσα στην CouponReminder έμεινε μόνο το ζουμί (single responsibility με τη γενικότερη έννοια) και όλες οι λεπτομέρειες έφυγαν στα dependencies. Αλλά τα dependencies τα ελέγχουμε εμείς και θα τα κάνουμε mock, οπότε μπορούμε να γράψουμε ο,τι κώδικα μας κάνει όρεξη για να κανονίσουμε τι γυμναστική θα κάνει το test στην CouponReminder. --- Έλεγα πριν για interfaces αντί για σκέτα classes. Ένας λόγος είναι ότι έτσι είναι πιο εύκολο το "mocking από το μηδέν", όπου το default είναι καμία method κανενός mock να μην κάνει απολύτως τίποτα και με opt-in εμείς κανονίζουμε ακριβώς σε ποιά σημεία μας ενδιαφέρει να γίνει τι, και μόνο αυτό θα γίνει. Έναν άλλο λόγο θα τον αφήσω για λίγο παρακάτω. Μια χρήσιμη καθημερινή τεχνική για mocking από το μηδέν είναι anonymous classes της PHP 7: interface IFoo { public function foo(); } $mockFoo = new class("Hello world!") implements IFoo { private $greeting; public function __construct($greeting) { $this->greeting = $greeting; } public function foo() { echo $this->greeting; } }; $mockFoo->foo(); // "Hello world!" Αυτό μπορείς να το κάνεις επιτόπου μέσα στο test case. Είναι λίγο φλύαρο αλλά δεν είναι πρόβλημα, το βάζεις πίσω από μια method που παίρνει τις παραμέτρους που θα πάνε στον constructor και επιστρέφει το mock αντικείμενο οπότε μέσα στην test case μένεις με μία όμορφη single γραμμή κώδικα. Είναι απλό στην εφαρμογή και όλος ο κώδικας είναι κανονικός όπως τον βλέπεις, μπορείς να τον κάνεις debug φυσιολογικά, όλα απλά λιτά κατανοητά. Το μειονέκτημά του είναι πως αν θέλεις να κωδικοποιήσεις nontrivial συμπεριφορά μέσα σε καμπόσες μεθόδους αρχίζει να γίνεται δυσάρεστη η διαδικασία "μεταφοράς του logic" μέσα στο mock. Φυσικά δεν τελειώνει εδώ η συζήτηση και οι ορίζοντες της τεχνικής αλλά ξεφεύγουμε. Μια άλλη τεχνική που συμπληρώνει την προηγούμενη είναι κάποιο mock library όπως το mockery που έδειξες στην αρχή. Εγώ δεν ξέρω από mockery, αλλά ξέρω από phpunit που χρησιμοποιείς ήδη στον κώδικα και με το οποίο you cannot go wrong. Οπότε θα μιλήσω συγκεκριμένα με όρους phpunit. Το αντίστοιχο λοιπόν με το παραπάνω σε phpunit θα ήταν κάτι του στυλ $greeting = "Hello world!"; $mockFoo = $this->getMockForAbstractClass(IFoo::class); $mockFoo->method('foo')->willReturnCallback(function() use ($greeting) { echo $greeting; }); $mockFoo->foo(); // "Hello world!"Δε θα μπω σε λεπτομέρειες, υπάρχουν πολλές δυνατότητες, read the manual and take it easy, είναι πολλά δε βγαίνουν με τη μία (ούτε με τις πέντε). Θα πω γενικά ότι στην ουσία το phpunit σου παρέχει ένα framework το οποίο μοντελοποιεί σε μικρά κομμάτια τη διαδικασία του τεστ. Υπάρχουν δηλαδή στο phpunit ιεραρχίες από classes που αντιπροσωπεύουν διάφορες παραλλαγές των π.χ. "τι κάνει μια mock method όταν την καλέσουν", "ποιά περιμένουμε να είναι η συμπεριφορά του κώδικα πάνω στην mock method" (πόσες φορές την καλεί, με τι ορίσματα, κλπ), "μια συνθήκη που αντιπροσωπεύει κάτι" (δύο τιμές να είναι ίσες, κλπ). Επίσης περιέχει και classes που σου παρέχουν ένα fluent interface για να ταιριάξεις αυτά τα lego blocks μαζί ούτως ώστε να δημιουργούν το περιβάλλον του test που θες. Είναι λοιπόν καλύτερο για τις πιο βαριές δουλειές προετοιμασίας του test, και βέβαια μπορείς να δημιουργήσεις δικά σου lego που ταιριάζουν με τα υπόλοιπα αρκεί να ακολουθείς την ίδια λογική. --- Τώρα πλέον είπαμε αρκετά για να βγάζει νόημα μια πρώτη απόπειρα για το unit test. Πρόσεξε πως ακριβώς επειδή είναι unit test και ελέγχουμε τα πάντα, ούτε mail θα σταλεί ούτε database χρειάζεται καν να υπάρχει. class CouponReminderTests extends PHPUnit\Framework\TestCase { public function testCoupon() { $expiredCoupons = [ // βάλε μερικά coupon objects εδώ όπως θα τα γυρνούσε η getExpiredCoupons() ]; // Αυτή εδώ η δουλειά θα έπρεπε να γίνει με κάποιο array_map σε μια άλλη method // (όχι όμως test method) στην ίδια την CouponReminderTests, και εδώ να μείνει μόνο // μια όμορφη single γραμμή που την καλούμε, αλλά βαριέμαι $expectedEmails = [ // φτιάξε τα πράγματα που θα έπρεπε να φτάνουν στη MailProvider::sendEmail() // ένας πίνακας από arguments της sendEmail για κάθε coupon, π.x. [ // ορίσματα για το πρώτο email [ // το πρώτο και μοναδικό όρισμα είναι το ίδιο πίνακας 'email' => $expiredCoupons[0]->email, 'body' => 'Coupon '.$expiredCoupons[0]->coupon_code.' is about to expire', ], ], ]; $repo = $this->getMockForAbstractClass(Foo::class); $repo->method('getExpiredCoupons')->willReturn($expectedResult); $clockProvider = new HostSystemClockTimeProvider(); // θα έπρεπε να είναι mock αλλά λεπτομέρειες $mailProvider = $this->getMockForAbstractClass(MailProvider::class); $mailProvider ->expects($this->exactly(count($expectedEmails))) ->method('sendEmail') ->withConsecutive($expectedEmails); $coup = new CouponReminder($clockProvider, $mailProvider, $repo); $coup->getNotExpired(); // go! } }Στην απίθανη περίπτωση που δεν έχει ξεφύγει κανένα απολύτως λάθος πουθενά, αυτό το τεστ πρέπει να τρέχει και να περνάει και να σου λέει 1 test Ν assertions (ανάλογα πόσα expired coupons έβαλες). Εδώ τώρα υπάρχει άπειρο πράγμα προς συζήτηση αλλά πιο μετά. Προς το παρόν ο στόχος είναι να περάσει το τεστ. Θα κλείσω απλά επισημαίνοντας μερικά γενικά χαρακτηριστικά του τι έχουμε τώρα στα χέρια μας. Όλες οι "είσοδοι" στην test case, δηλαδή όλα κι όλα τα πράγματα που πρέπει να πειράξεις για να δημιουργήσεις πλήρως το ελεγχόμενο περιβάλλον του test, είναι βέβαια τα dependencies. Με mock των dependencies καθορίζεις τη λογική με την οποία θα λειτουργήσουν. Βασικά κομμάτια της εισόδου αυτής θα είναι κάποια έτοιμα γεμάτα data structures -- κλασική περίπτωση, το argument που θα περαστεί σε μια method και το αποτέλεσμα που περιμένουμε αυτή να μας γυρίσει -- τα οποία θα χρησιμοποιηθούν για τη ρύθμιση των mock deps όπως κάνουμε εδώ με τα $expected. Αυτά τα data structures βλέπεις ότι με κατάλληλη οργάνωση μπορούμε να τα έχουμε ένα ανά μεταβλητή και να τα αλλάζουμε και να τα πειράζουμε και να ξανατρέχουμε το test όσες φορές μας αρέσει με τις διαφορετικές τιμές. Αυτό σημαίνει ότι θα είναι πανεύκολο να δοκιμάζουμε διαφορετικά σενάρια input values το καθένα ξεχωριστά για να καλύψουμε όλες τις περιπτώσεις που θέλουμε χωρίς να γράφουμε το ίδιο test logic κάθε φορά. Δηλαδή, η κάθε test case θα κωδικοποιεί τη "λογική" για ένα είδος test, και θα παίρνει arguments (εδώ δεν παίρνει ακόμα) τα "δεδομένα" πάνω στα οποία θα τρέξει το test. Ο τρόπος με τον οποίο ορίζουμε τι θα γίνει και τι αποτέλεσμα περιμένουμε να έχει είναι πολύ βολικός και περιγραφικός: "H getExpiredCoupons θα επιστρέψει αυτό", "περιμένω ότι θα κληθεί ακριβώς Χ φορές η sendEmail, και κάθε φορά θα καλείται κατά σειρά με τα παρακάτω σετ ορισμάτων". Το χουμε;
Προτεινόμενες αναρτήσεις
Δημιουργήστε ένα λογαριασμό ή συνδεθείτε για να σχολιάσετε
Πρέπει να είστε μέλος για να αφήσετε σχόλιο
Δημιουργία λογαριασμού
Εγγραφείτε με νέο λογαριασμό στην κοινότητα μας. Είναι πανεύκολο!
Δημιουργία νέου λογαριασμούΣύνδεση
Έχετε ήδη λογαριασμό; Συνδεθείτε εδώ.
Συνδεθείτε τώρα