Εισαγωγή στην ανάπτυξη βάσει δοκιμών

Προγραμματίζω για πέντε χρόνια και, ειλικρινά, έχω αποφύγει την ανάπτυξη βάσει δοκιμών. Δεν το έχω αποφύγει γιατί δεν πίστευα ότι ήταν σημαντικό. Στην πραγματικότητα, φαινόταν πολύ σημαντικό - αλλά μάλλον επειδή ήμουν πολύ άνετα να μην το κάνω. Αυτό άλλαξε.

Τι είναι η δοκιμή;

Η δοκιμή είναι η διαδικασία διασφάλισης ότι ένα πρόγραμμα λαμβάνει τη σωστή είσοδο και παράγει τη σωστή έξοδο και τις προβλεπόμενες παρενέργειες. Ορίζουμε αυτές τις σωστές εισόδους, εξόδους και παρενέργειες με προδιαγραφές . Μπορεί να έχετε δει δοκιμές αρχείων με τη σύμβαση ονομασίας filename.spec.js. Το specπερίγραμμα προδιαγραφών. Είναι το αρχείο όπου καθορίζουμε ή επιβεβαιώνουμε τι πρέπει να κάνει ο κώδικάς μας και στη συνέχεια δοκιμάστε τον για να επαληθεύσετε ότι το κάνει.

Έχετε δύο επιλογές όσον αφορά τις δοκιμές: μη αυτόματες δοκιμές και αυτοματοποιημένες δοκιμές.

Μη αυτόματη δοκιμή

Η μη αυτόματη δοκιμή είναι η διαδικασία ελέγχου της εφαρμογής ή του κώδικα από την οπτική γωνία του χρήστη. Άνοιγμα του προγράμματος περιήγησης ή του προγράμματος και πλοήγηση σε μια προσπάθεια δοκιμής της λειτουργικότητας και εύρεσης σφαλμάτων.

Αυτοματοποιημένες δοκιμές

Οι αυτοματοποιημένες δοκιμές, από την άλλη πλευρά, γράφουν κώδικα που ελέγχει αν λειτουργεί άλλος κώδικας. Σε αντίθεση με τις χειροκίνητες δοκιμές, οι προδιαγραφές παραμένουν σταθερές από δοκιμή σε δοκιμή. Το μεγαλύτερο πλεονέκτημα είναι να δοκιμάζουμε πολλά πράγματα πολύ πιο γρήγορα.

Είναι ο συνδυασμός αυτών των δύο τεχνικών δοκιμών που θα ξεφορτωθούν όσο το δυνατόν περισσότερα σφάλματα και ανεπιθύμητες παρενέργειες και θα διασφαλίσουν ότι το πρόγραμμά σας θα κάνει ό, τι λέτε ότι θα κάνει. Το επίκεντρο αυτού του άρθρου είναι στις αυτοματοποιημένες δοκιμές και συγκεκριμένα στις δοκιμές μονάδας.

Υπάρχουν δύο βασικοί τύποι αυτοματοποιημένων δοκιμών: Μονάδα και End-to-End (E2E). Οι δοκιμές E2E ελέγχουν μια εφαρμογή στο σύνολό της. Οι δοκιμές μονάδας δοκιμάζουν τα μικρότερα κομμάτια κώδικα ή μονάδες. Τι είναι μια μονάδα; Λοιπόν, ορίζουμε τι είναι μια μονάδα, αλλά γενικά, είναι ένα σχετικά μικρό κομμάτι της λειτουργικότητας της εφαρμογής.

Ανακεφαλαιώσουμε:

  1. Η δοκιμή επιβεβαιώνει ότι η εφαρμογή μας κάνει ό, τι πρέπει.
  2. Υπάρχουν δύο τύποι δοκιμών: χειροκίνητες και αυτοματοποιημένες
  3. Οι δοκιμές επιβεβαιώνουν ότι το πρόγραμμά σας θα συμπεριφέρεται με συγκεκριμένο τρόπο. Τότε το ίδιο το τεστ αποδεικνύει ή διαψεύδει αυτόν τον ισχυρισμό.

Ανάπτυξη βάσει δοκιμών

Η δοκιμαστική ανάπτυξη είναι η πράξη να αποφασίσετε πρώτα τι θέλετε να κάνει το πρόγραμμά σας (τις προδιαγραφές), διατυπώνοντας μια δοκιμασία που αποτυγχάνει και, στη συνέχεια, γράφοντας τον κωδικό για να περάσει αυτό το τεστ. Συνήθως σχετίζεται με αυτοματοποιημένες δοκιμές. Παρόλο που θα μπορούσατε επίσης να εφαρμόσετε τις αρχές στις μη αυτόματες δοκιμές.

Ας δούμε ένα απλό παράδειγμα: Χτίζοντας ένα ξύλινο τραπέζι. Παραδοσιακά, θα φτιάξαμε ένα τραπέζι και, στη συνέχεια, μόλις φτιαχτεί ο πίνακας, θα το δοκιμάσουμε για να βεβαιωθούμε ότι κάνει, τι πρέπει να κάνει ένας πίνακας. Το TDD, από την άλλη πλευρά, θα μας ζητούσε πρώτα να καθορίσουμε τι πρέπει να κάνει ο πίνακας. Στη συνέχεια, όταν δεν κάνει αυτά τα πράγματα, προσθέστε το ελάχιστο ποσό "πίνακα" για να κάνετε κάθε μονάδα να λειτουργεί.

Εδώ είναι ένα παράδειγμα TDD για την κατασκευή ενός ξύλινου πίνακα:

I expect the table to be four feet in diameter. The test fails because I have no table. I cut a circular piece of wood four feet in diameter. The test passes. __________ I expect the table to be three feet high. The test fails because it is sitting on the ground. I add one leg in the middle of the table. The test passes. __________ I expect the table to hold a 20-pound object. The test fails because when I place the object on the edge, it makes the table fall over since there is only one leg in the middle. I move the one leg to the outer edge of the table and add two more legs to create a tripod structure. The test passes.

Αυτό θα συνεχιζόταν συνεχώς μέχρι να ολοκληρωθεί ο πίνακας.

ανακεφαλαιώσουμε

  1. Με το TDD, η λογική δοκιμής προηγείται της λογικής εφαρμογής.

Ένα πρακτικό παράδειγμα

Φανταστείτε ότι έχουμε ένα πρόγραμμα που διαχειρίζεται τους χρήστες και τις αναρτήσεις τους στο blog Χρειαζόμαστε έναν τρόπο να παρακολουθούμε τις αναρτήσεις που γράφει ένας χρήστης στη βάση δεδομένων μας με μεγαλύτερη ακρίβεια. Αυτήν τη στιγμή, ο χρήστης είναι ένα αντικείμενο με όνομα και ιδιότητα email:

user = { name: 'John Smith', email: '[email protected]' }

Θα παρακολουθούμε τις αναρτήσεις που δημιουργεί ένας χρήστης στο ίδιο αντικείμενο χρήστη.

user = { name: 'John Smith', email: '[email protected]' posts: [Array Of Posts] // <----- }

Κάθε ανάρτηση έχει τίτλο και περιεχόμενο. Αντί να αποθηκεύουμε ολόκληρη την ανάρτηση σε κάθε χρήστη, θα θέλαμε να αποθηκεύσουμε κάτι μοναδικό που θα μπορούσε να χρησιμοποιηθεί για αναφορά στην ανάρτηση. Αρχικά σκεφτήκαμε ότι θα αποθηκεύαμε τον τίτλο. Ωστόσο, εάν ο χρήστης αλλάξει ποτέ τον τίτλο, ή εάν –αν και μάλλον απίθανο– δύο τίτλοι είναι ακριβώς οι ίδιοι, θα είχαμε κάποια προβλήματα σχετικά με αυτήν την ανάρτηση ιστολογίου. Αντ 'αυτού, θα δημιουργήσουμε ένα μοναδικό αναγνωριστικό για κάθε ανάρτηση ιστολογίου που θα αποθηκεύσουμε στο userαντικείμενο.

user = { name: 'John Smith', email: '[email protected]' posts: [Array Of Post IDs] }

Ρυθμίστε το περιβάλλον δοκιμών μας

Για αυτό το παράδειγμα, θα χρησιμοποιούμε το Jest. Το Jest είναι μια δοκιμαστική σουίτα. Συχνά, θα χρειαστείτε μια βιβλιοθήκη δοκιμών και μια ξεχωριστή βιβλιοθήκη ισχυρισμών, αλλά το Jest είναι μια λύση all-in-one.

Μια βιβλιοθήκη ισχυρισμών μας επιτρέπει να κάνουμε ισχυρισμούς σχετικά με τον κώδικά μας. Έτσι, στο ξύλινο τραπέζι μας, ο ισχυρισμός μας είναι: «Περιμένω από το τραπέζι να έχει ένα αντικείμενο 20 λιβρών». Με άλλα λόγια, δηλώνω κάτι για το τι πρέπει να κάνει ο πίνακας.

Ρύθμιση έργου

  1. Δημιουργήστε ένα έργο NPM: npm init.
  2. Δημιουργήστε id.jsκαι προσθέστε το στη ρίζα του έργου.
  3. Εγκατάσταση Jest: npm install jest --D
  4. Ενημερώστε το testσενάριο package.json
// package.json { ...other package.json stuff "scripts": { "test": "jest" // this will run jest with "npm run test" } }

Αυτό είναι για τη ρύθμιση του έργου! Δεν θα έχουμε HTML ή στυλ. Το προσεγγίζουμε αυτό καθαρά από τη σκοπιά της δοκιμής μονάδας. Και, είτε το πιστεύετε είτε όχι, έχουμε αρκετό χρόνο να τρέξουμε τον Jest.

Στη γραμμή εντολών, εκτελέστε σενάριο δοκιμής μας: npm run test.

Θα πρέπει να λάβατε ένα σφάλμα:

No tests found In /****/ 3 files checked. testMatch: **/__tests__/**/*.js?(x),**/?(*.)+(spec|test).js?(x) - 0 matches testPathIgnorePatterns: /node_modules/ - 3 matches

Το Jest αναζητά ένα όνομα αρχείου με ορισμένα συγκεκριμένα χαρακτηριστικά, όπως ένα .specή που .testπεριέχεται στο όνομα του αρχείου.

Ας ενημερώσουμε id.jsγια να είμαστε id.spec.js.

Εκτελέστε ξανά το τεστ

Θα πρέπει να λάβετε ένα άλλο σφάλμα:

FAIL ./id.spec.js ● Test suite failed to run Your test suite must contain at least one test.

Λίγο καλύτερα, βρήκε το αρχείο, αλλά όχι ένα τεστ. Οτι έχει νόημα; είναι ένα άδειο αρχείο.

Πώς γράφουμε ένα τεστ;

Οι δοκιμές είναι απλώς συναρτήσεις που λαμβάνουν μερικά επιχειρήματα. Μπορούμε να καλέσετε δοκιμής μας είτε με it()είτε test().

it()είναι ένα ψευδώνυμο του test().

Ας γράψουμε μια πολύ βασική δοκιμή για να βεβαιωθούμε ότι η Jest λειτουργεί.

// id.spec.js test('Jest is working', () => { expect(1).toBe(1); });

Εκτελέστε ξανά το τεστ.

PASS ./id.spec.js ✓ Jest is working (3ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 1.254s Ran all test suites.

Περάσαμε το πρώτο μας τεστ! Ας αναλύσουμε το αποτέλεσμα της δοκιμής και των αποτελεσμάτων.

Περνάμε έναν τίτλο ή περιγραφή ως το πρώτο επιχείρημα.

test('Jest is Working')

The second argument we pass is a function where we actually assert something about our code. Although, in this case, we aren’t asserting something about our code, but rather something truthy in general that will pass, a sort of sanity check.

...() => { expect(1).toBe(1) });

This assertion is mathematically true, so it’s a simple test to ensure we’ve wired up Jest correctly.

The results tell us whether the test passes or fails. It also tells us the number of tests and test suites.

A side note about organizing our tests

There is another way we could organize our code. We could wrap each test in a describe function.

describe('First group of tests', () => { test('Jest is working', () => { expect(1).toBe(1); }); }); describe('Another group of tests', () => { // ...more tests here });

describe() allows us to divide up our tests into sections:

PASS ./id.spec.js First group of tests ✓ Jest is working(4ms) ✓ Some other test (1ms) Another group of tests ✓ And another test ✓ One more test (12ms) ✓ And yes, one more test

We won’t use describe, but it is more common than not to see a describe function that wraps tests. Or even a couple of describes–maybe one for each file we are testing. For our purposes, we will just focus on test and keep the files fairly simple.

Testing Based on Specifications

As tempting as it is to just sit down and start typing application logic, a well-formulated plan will make development easier. We need to define what our program will do. We define these goals with specifications.

Our high-level specification for this project is to create a unique ID, although we should break that down into smaller units that we will test. For our small project we will use the following specifications:

  1. Create a random number
  2. The number is an integer.
  3. The number created is within a specified range.
  4. The number is unique.

Recap

  1. Jest is a testing suite and has a built-in assertion library.
  2. A test is just a function whose arguments define the test.
  3. Specifications define what our code should do and are ultimately what we test.

Specification 1: Create a Random Number

JavaScript has a built-in function to create random numbers–Math.random(). Our first unit test will look to see that a random number was created and returned. What we want to do is use math.random() to create a number and then ensure that is the number that gets returned.

So you might think we would do something like the following:

expect(our-functions-output).toBe(some-expected-value). The problem with our return value being random, is we have no way to know what to expect. We need to re-assign the Math.random() function to some constant value. This way, when our function runs, Jest replaces Math.random()with something constant. This process is called mocking. So, what we are really testing for is that Math.random()gets called and returns some expected value that we can plan for.

Now, Jest also provides a way to prove a function is called. However, in our example, that assertion alone only assures us Math.random()was called somewhere in our code. It won’t tell us that the result of Math.random()was also the return value.

Γιατί θα θέλατε να κοροϊδέψετε μια λειτουργία; Δεν είναι το σημείο να δοκιμάσετε τον πραγματικό κώδικα; Ναι και ΟΧΙ. Πολλές συναρτήσεις περιέχουν πράγματα που δεν μπορούμε να ελέγξουμε, για παράδειγμα ένα αίτημα HTTP. Δεν προσπαθούμε να δοκιμάσουμε αυτόν τον κωδικό. Υποθέτουμε ότι αυτές οι εξαρτήσεις θα κάνουν ό, τι πρέπει ή κάνουν προσποιητικές λειτουργίες που προσομοιώνουν τη συμπεριφορά τους. Και, σε περίπτωση που αυτές είναι εξαρτήσεις που έχουμε γράψει, πιθανότατα θα γράψουμε ξεχωριστές δοκιμές για αυτές.

Προσθέστε την ακόλουθη δοκιμή στο id.spec.js

test('returns a random number', () => { const mockMath = Object.create(global.Math); mockMath.random = jest.fn(() => 0.75); global.Math = mockMath; const id = getNewId(); expect(id).toBe(0.75); });

Καταργώντας την παραπάνω δοκιμή

Πρώτον, αντιγράφουμε το καθολικό αντικείμενο Math. Στη συνέχεια αλλάζουμε τη randomμέθοδο για να επιστρέψουμε μια σταθερή τιμή, κάτι που μπορούμε να περιμένουμε . Τέλος, αντικαθιστούμε το παγκόσμιο Mathαντικείμενο με το πλαστό Mathαντικείμενο.

Πρέπει να λάβουμε ένα αναγνωριστικό από μια συνάρτηση (που δεν έχουμε δημιουργήσει ακόμα - θυμηθείτε αυτό το TDD). Στη συνέχεια, αναμένουμε ότι το αναγνωριστικό θα είναι ίσο με 0,75 - η πλαστή τιμή επιστροφής μας.

Ανακοίνωση Έχω επιλέξει να χρησιμοποιήσετε μια ενσωματωμένη μέθοδο που Jest προβλέπει σκωπτική λειτουργίες: jest.fn(). Θα μπορούσαμε επίσης να περάσουμε σε ανώνυμη συνάρτηση. Ωστόσο, θα ήθελα να σας δείξω αυτήν τη μέθοδο, καθώς θα υπάρξουν στιγμές που θα χρειαστεί μια συνάρτηση Jest-mocked για να λειτουργήσει άλλη λειτουργικότητα στις δοκιμές μας.

Εκτελέστε τη δοκιμή: npm run test

FAIL ./id.spec.js ✕ returns a random number (4ms) ● returns a random number ReferenceError: getNewId is not defined

Παρατηρήστε ότι έχουμε ένα σφάλμα αναφοράς όπως θα έπρεπε. Το τεστ μας δεν μπορεί να βρει το δικό μας getNewId().

Προσθέστε τον ακόλουθο κωδικό πάνω από τη δοκιμή.

function getNewId() { Math.random() }
Κρατάω τον κώδικα και δοκιμάζω στο ίδιο αρχείο για απλότητα. Κανονικά, η δοκιμή θα γραφτεί σε ξεχωριστό αρχείο, με τυχόν εξαρτήσεις που θα εισαχθούν ανάλογα με τις ανάγκες τους.
FAIL ./id.spec.js ✕ returns a random number (4ms) ● returns a random number expect(received).toBe(expected) // Object.is equality Expected: 0.75 Received: undefined

Αποτύχαμε ξανά με αυτό που ονομάζεται σφάλμα ισχυρισμού . Το πρώτο μας σφάλμα ήταν ένα σφάλμα αναφοράς. Αυτό το δεύτερο σφάλμα μας λέει ότι έλαβε undefined. Αλλά το κάναμε Math.random()έτσι τι συνέβη; Θυμηθείτε, οι λειτουργίες που δεν επιστρέφουν ρητά κάτι θα επιστρέψουν σιωπηρά undefined. Αυτό το σφάλμα είναι μια καλή υπόδειξη ότι κάτι δεν ορίστηκε όπως μια μεταβλητή ή, όπως στην περίπτωσή μας, η λειτουργία μας δεν επιστρέφει τίποτα.

Ενημερώστε τον κωδικό στα ακόλουθα:

function getNewId() { return Math.random() }

Εκτελέστε τη δοκιμή

PASS ./id.spec.js ✓ returns a random number (1ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total

Συγχαρητήρια! Περάσαμε το πρώτο μας τεστ.

Στην ιδανική περίπτωση, θέλουμε να φτάσουμε στα λάθη των ισχυρισμών μας το συντομότερο δυνατό. Σφάλματα διεκδίκησης - συγκεκριμένα εκτιμήστε λάθη επιβεβαίωσης όπως αυτό, αν και θα αγγίξουμε σε δυαδικά σφάλματα ισχυρισμών σε λίγο - μας δίνουν συμβουλές για το τι είναι λάθος.

Specification 2: The number we return is an integer.

Math.random() generates a number between 0 and 1 (not inclusive). The code we have will never generate such an integer. That’s ok though, this is TDD. We will check for an integer and then write the logic to transform our number to an integer.

So, how do we check if a number is an integer? We have a few options. Recall, we mocked Math.random() above, and we are returning a constant value. In fact, we are creating a real value as well since we are returning a number between 0 and 1 (not inclusive). If we were returning a string, for example, we couldn’t get this test to pass. Or if on the other hand, we were returning an integer for our mocked value, the test would always (falsely) pass.

So a key takeaway is if you going to use mocked return values, they should be realistic so our tests return meaningful information with those values.

Another option would be to use the Number.isInteger(), passing our ID as the argument and seeing if that returns true.

Finally, without using the mocked values, we could compare the ID we get back with its integer version.

Let’s look at option 2 and 3.

Option 2: Using Number.isInteger()

test('returns an integer', () => { const id = getRandomId(); expect(Number.isInteger(id)).toBe(true); });

The test fails as it should.

FAIL ./id.spec.js ✓ returns a random number (1ms) ✕ returns an integer (3ms) ● returns an integer expect(received).toBe(expected) // Object.is equality Expected: true Received: false

The test fails with a boolean assertion error. Recall, there are multiple ways a test might fail. We want them to fail with assertion errors. In other words, our assertion isn’t what we say it is. But even more so, we want our test to fail with value assertion errors.

Boolean assertion errors (true/false errors) don’t give us very much information, but a value assertion error does.

Let’s return to our wooden table example. Now bear with me, the following two statements might seem awkward and difficult to read, but they’re here to highlight a point:

First, you might assert that the table is blue [to be] true. In another assertion, you might assert the table color [to be] blue. I know, these are awkward to say and might even look like identical assertions but they're not. Take a look at this:

expect(table.isBlue).toBe(true)

vs

expect(table.color).toBe(blue)

Assuming the table isn’t blue, the first examples error will tell us it expected true but received false. You have no idea what color the table is. We very well may have forgotten to paint it altogether. The second examples error, however, might tell us it expected blue but received red. The second example is much more informative. It points to the root of the problem much quicker.

Let’s rewrite the test, using option 2, to receive a value assertion error instead.

test('returns an integer', () => { const id = getRandomId(); expect(id).toBe(Math.floor(id)); });

We are saying we expect the ID we get from our function to be equal to the floor of that ID. In other words, if we are getting an integer back, then the floor of that integer is equal to the integer itself.

FAIL ./id.spec.js ✓ returns a random number (1ms) ✕ returns an integer (4ms) ● returns an integer expect(received).toBe(expected) // Object.is equality Expected: 0 Received: 0.75

Wow, what are the chances this function just happened to return the mocked value! Well, they are 100% actually. Even though our mocked value seems to be scoped to only the first test, we are actually reassigning the global value. So no matter how nested that re-assignment takes place, we are changing the global Math object.

If we want to change something before each test, there is a better place to put it. Jest offers us a beforeEach() method. We pass in a function that runs any code we want to run before each of our tests. For example:

beforeEach(() => { someVariable = someNewValue; }); test(...)

For our purposes, we won’t use this. But let's change our code a bit so that we reset the global Math object back to the default. Go back into the first test and update the code as follows:

test('returns a random number', () => { const originalMath = Object.create(global.Math); const mockMath = Object.create(global.Math); mockMath.random = () => 0.75; global.Math = mockMath; const id = getNewId(); expect(id).toBe(0.75); global.Math = originalMath; });

What we do here is save the default Math object before we overwrite any of it, then reassign it after our test is complete.

Let’s run our tests again, specifically focusing back on our second test.

✓ returns a random number (1ms) ✕ returns an integer (3ms) ● returns an integer expect(received).toBe(expected) // Object.is equality Expected: 0 Received: 0.9080890805713182

Since we’ve updated our first test to go back to the default Math object, we are truly getting a random number now. And just like the test before, we are expecting to receive an integer, or in other words, the floor of the number generated.

Update our application logic.

function getRandomId() { return Math.floor(Math.random()); // convert to integer } FAIL ./id.spec.js ✕ returns a random number (5ms) ✓ returns an integer ● returns a random number expect(received).toBe(expected) // Object.is equality Expected: 0.75 Received: 0

Uh oh, our first test failed. So what happened?

Well, because we are mocking our return value. Our first test returns 0.75, no matter what. We expect, however, to get 0 (the floor of 0.75). Maybe it would be better to check if Math.random() gets called. Although, that is somewhat meaningless, because we could call Math.random()anywhere in our code, never use it, and the test still passes. Maybe, we should test whether our function returns a number. After all, our ID must be a number. Yet again, we are already testing if we are receiving an integer. And all integers are numbers; that test would be redundant. But there is one more test we could try.

When it is all said and done, we expect to get an integer back. We know we will use Math.floor() to do so. So maybe we can check if Math.floor() gets called with Math.random() as an argument.

test('returns a random number', () => { jest.spyOn(Math, 'floor'); //  0.75; global.Math = mockMath; const id = getNewId(); getNewId(); //<------------------------------------changed expect(Math.floor).toHaveBeenCalledWith(0.75); //<-changed global.Math = globalMath; });

I’ve commented the lines we changed. First, move your attention towards the end of the snippet. We are asserting that a function was called. Now, go back to the first change: jest.spyOn(). In order to watch if a function has been called, jest requires us to either mock that function, or spy on it. We’ve already seen how to mock a function, so here we spy on Math.floor(). Finally, the other change we’ve made was to simply call getNewId() without assigning its return value to a variable. We are not using the ID, we are simply asserting it calls some function with some argument.

Run our tests

PASS ./id.spec.js ✓ returns a random number (1ms) ✓ returns an integer Test Suites: 1 passed, 1 total Tests: 2 passed, 2 total

Congratulations on a second successful test.

Specification 3: The number is within a specified range.

We know Math.random() returns a random number between 0 and 1 (not inclusive). If the developer wants to return a number between 3 and 10, what could she do?

Here is the answer:

Math.floor(Math.random() * (max — min + 1))) + min;

The above code will produce a random number in a range. Let’s look at two examples to show how it works. I’ll simulate two random numbers being created and then apply the remainder of the formula.

Example: A number between 3 and 10. Our random numbers will be .001 and .999. I’ve chosen the extreme values as the random numbers so you could see the final result stays within the range.

0.001 * (10-3+1) + 3 = 3.008 the floor of that is 3

0.999 * (10-3+1) + 3 = 10.992 the floor of that is 10

Let’s write a test

test('generates a number within a specified range', () => { const id = getRandomId(10, 100); expect(id).toBeLessThanOrEqual(100); expect(id).toBeGreaterThanOrEqual(10); }); FAIL ./id.spec.js ✓ returns a random number (1ms) ✓ returns an integer (1ms) ✕ generates a number within a specified range (19ms) ● generates a number within a specified range expect(received).toBeGreaterThanOrEqual(expected) Expected: 10 Received: 0

The floor of Math.random() will always be 0 until we update our code. Update the code.

function getRandomId(min, max) { return Math.floor(Math.random() * (max - min + 1) + min); } FAIL ./id.spec.js ✕ returns a random number (5ms) ✓ returns an integer (1ms) ✓ generates a number within a specified range (1ms) ● returns a random number expect(jest.fn()).toHaveBeenCalledWith(expected) Expected mock function to have been called with: 0.75 as argument 1, but it was called with NaN.

Oh no, our first test failed again! What happened?

Simple, our test is asserting that we are calling Math.floor() with 0.75. However, we actually call it with 0.75 plus and minus a max and min value that isn’t yet defined. Here we will re-write the first test to include some of our new knowledge.

test('returns a random number', () => { jest.spyOn(Math, 'floor'); const mockMath = Object.create(global.Math); const originalMath = Object.create(global.Math); mockMath.random = () => 0.75; global.Math = mockMath; const id = getNewId(10, 100); expect(id).toBe(78); global.Math = originalMath; }); PASS ./id.spec.js ✓ returns a random number (1ms) ✓ returns an integer ✓ generates a number within a specified range (1ms) Test Suites: 1 passed, 1 total Tests: 3 passed, 3 total

We’ve made some pretty big changes. We’ve passed some sample numbers into our function (10, and 100 as minimum and maximum values), and we’ve changed our assertion once again to check for a certain return value. We can do this because we know if Math.random() gets called, the value is set to 0.75. And, when we apply our min and max calculations to 0.75 we will get the same number each time, which in our case is 78.

Now we have to start wondering if this is even a good test. We’ve had to go back in and mold our test to fit our code. That goes against the spirit of TDD a bit. TDD says to change your code to make the test pass, not to change the test to make the test pass. If you find yourself trying to fix tests so they pass, that may be a sign of a bad test. Yet, I’d like to leave the test in here, as there are a couple of good concepts. However, I urge you to consider the efficacy of a test such as this, as well as a better way to write it, or if it’s even critical to include at all.

Let’s return to our third test which was generating a number within a range.

We see it has passed, but we have a problem. Can you think of it?

The question I am wondering is whether we just get lucky? We only generated a single random number. What are the chances that number just happened to be in the range and pass the test?

Fortunately here, we can mathematically prove our code works. However, for fun (if you can call it fun), we will wrap our code in a for loop that runs 100 times.

test('generates a number within a defined range', () => { for (let i = 0; i < 100; i ++) { const id = getRandomId(10, 100); expect(id).toBeLessThanOrEqual(100); expect(id).toBeGreaterThanOrEqual(10); expect(id).not.toBeLessThan(10); expect(id).not.toBeGreaterThan(100); } });

I added a few new assertions. I use the .not only to demonstrate other Jest API’s available.

PASS ./id.spec.js ✓ is working (2ms) ✓ Math.random() is called within the function (3ms) ✓ receives an integer from our function (1ms) ✓ generates a number within a defined range (24ms) Test Suites: 1 passed, 1 total Tests: 4 passed, 4 total Snapshots: 0 total Time: 1.806s

With 100 iterations, we can feel fairly confident our code keeps our ID within the specified range. You could also purposely try to fail the test for added confirmation. For example, you could change one of the assertions to not expect a value greater than 50 but still pass in 100 as the maximum argument.

Is it ok to use multiple assertions in one test?

Yes. That isn’t to say you shouldn’t attempt to reduce those multiple assertions to a single assertion that is more robust. For example, we could rewrite our test to be more robust and reduce our assertions to just one.

test('generates a number within a defined range', () => { const min = 10; const max = 100; const range = []; for (let i = min; i < max+1; i ++) { range.push(i); } for (let i = 0; i < 100; i ++) { const id = getRandomId(min, max); expect(range).toContain(id); } });

Here, we created an array that contains all the numbers in our range. We then check to see if the ID is in the array.

Specification 4: The number is unique

How can we check if a number is unique? First, we need to define what unique to us means. Most likely, somewhere in our application, we would have access to all ID’s being used already. Our test should assert that the number that is generated is not in the list of current IDs. There are a few different ways to solve this. We could use the .not.toContain() we saw earlier, or we could use something with index.

indexOf()

test('generates a unique number', () => { const id = getRandomId(); const index = currentIds.indexOf(id); expect(index).toBe(-1); });

array.indexOf() returns the position in the array of the element you pass in. It returns -1 if the array doesn’t contain the element.

FAIL ./id.spec.js ✓ returns a random number (1ms) ✓ returns an integer ✓ generates a number within a defined range (25ms) ✕ generates a unique number (10ms) ● generates a unique number ReferenceError: currentIds is not defined

The test fails with a reference error. currentIds is not defined. Let's add an array to simulate some ID’s that might already exist.

const currentIds = [1, 3, 2, 4];

Re-run the test.

PASS ./id.spec.js ✓ returns a random number (1ms) ✓ returns an integer ✓ generates a number within a defined range (27ms) ✓ generates a unique number Test Suites: 1 passed, 1 total Tests: 4 passed, 4 total

While the test passes, this should once again raise a red flag. We have absolutely nothing that ensures the number is unique. So, what happened?

Again, we are getting lucky. In fact, your test may have failed. Although if you ran it over and over, you’d likely get a mix of both with far more passes than failures due to the size of currentIds.

One thing we could try is to wrap this in a for loop. A large enough for loop would likely cause us to fail, although it would be possible they all pass. What we could do is check to see that our getNewId() function could somehow be self-aware when a number is or is not unique.

For example. we could set currentIds = [1, 2, 3, 4, 5]. Then call getRandomId(1, 5) . Our function should realize there is no value it can generate due to the constraints and pass back some sort of error message. We could test for that error message.

test('generates a unique number', () => { mockIds = [1, 2, 3, 4, 5]; let id = getRandomId(1, 5, mockIds); expect(id).toBe('failed'); id = getRandomId(1, 6, mockIds); expect(id).toBe(6); });

There are a few things to notice. There are two assertions. In the first assertion, we expect our function to fail since we constrain it in a way that it shouldn’t return any number. In the second example, we constrain it in a way where it should only be able to return 6.

FAIL ./id.spec.js ✓ returns a random number (1ms) ✓ returns an integer (1ms) ✓ generates a number within a defined range (24ms) ✕ generates a unique number (6ms) ● generates a unique number expect(received).toBe(expected) // Object.is equality Expected: "failed" Received: 1

Our test fails. Since our code isn’t checking for anything or returning failed, this is expected. Although, it is possible your code received a 2 through 6.

How can we check if our function can’t find a unique number?

First, we need to do some sort of loop that will continue creating numbers until it finds one that’s valid. At some point though, if there are no valid numbers, we need to exit the loop so we avoid an infinite loop situation.

What we will do is keep track of each number we’ve created, and when we’ve created every number we can, and none of those numbers pass our unique check, we will break out of the loop and provide some feedback.

function getNewId(min = 0, max = 100, ids =[]) { let id; do { id = Math.floor(Math.random() * (max - min + 1)) + min; } while (ids.indexOf(id) > -1); return id; }

First, we refactored getNewId() to include a parameter that is a list of current ID’s. In addition, we updated our parameters to provide default values in the event they aren’t specified.

Second, we use a do-while loop since we don’t know how many times it will take to create a random number that is unique. For example, we could specify a number from 1to 1000 with the only number unavailable being 7. In other words, our current ID’s only has a single 7 in it. Although our function has 999 other numbers to choose from, it could theoretically produce the number 7 over and over again. While this is very unlikely, we use a do-while loop since we are not sure how many times it will run.

Additionally, notice we break out of the loop when our ID is unique. We determine this with indexOf().

We still have a problem, with the code currently how it is, if there are no numbers available, the loop will continue to run and we will be in an infinite loop. We need to keep track of all the numbers we create, so we know when we’ve run out of numbers.

function getRandomId(min = 0, max = 0, ids =[]) { let id; let a = []; do { id = Math.floor(Math.random() * (max - min + 1)) + min; if (a.indexOf(id) === -1) { a.push(id); } if (a.length === max - min + 1) { if (ids.indexOf(id) > -1) { return 'failed'; } } } while (ids.indexOf(id) > -1); return id; }

Here is what we did. We solve this problem by creating an array. And every time we create a number, add it to the array (unless it already in there). We know we’ve tried every number at least once when the length of that array is equal to the range we’ve chosen plus one. If we get to that point, we’ve created the last number. However, we still want to make sure the last number we created doesn’t pass the unique test. Because if it does, although we want the loop to be over, we still want to return that number. If not, we return “failed”.

PASS ./id.spec.js ✓ returns a random number (1ms) ✓ returns an integer (1ms) ✓ generates a number within a defined range (24ms) ✓ generates a unique number (1ms) Test Suites: 1 passed, 1 total Tests: 4 passed, 4 total

Congratulations, we can ship our ID generator and make our millions!

Conclusion

Some of what we did was for demonstration purposes. Testing whether our number was within a specified range is fun, but that formula can be mathematically proven. So a better test might be to make sure the formula is called.

Also, you could get more creative with the random ID generator. For example, if it can’t find a unique number, the function could automatically increase the range by one.

One other thing we saw was how our tests and even specifications might crystalize a bit as we test and refactor. In other words, it would be silly to think nothing will change throughout the process.

Τελικά, η δοκιμαστική ανάπτυξη μας παρέχει ένα πλαίσιο για να σκεφτούμε τον κώδικά μας σε πιο αναλυτικό επίπεδο. Εναπόκειται σε εσάς, τον προγραμματιστή, να καθορίσετε πόσο λεπτομερείς θα πρέπει να καθορίσετε τις δοκιμές και τους ισχυρισμούς σας. Λάβετε υπόψη ότι, όσο περισσότερες δοκιμές έχετε και όσο πιο στενά εστιάζουν οι δοκιμές σας, τόσο πιο στενά συνδεδεμένες γίνονται με τον κωδικό σας. Αυτό μπορεί να προκαλέσει απροθυμία στο refactor γιατί τώρα πρέπει επίσης να ενημερώσετε τις δοκιμές σας. Υπάρχει σίγουρα μια ισορροπία στον αριθμό και την λεπτομέρεια των δοκιμών σας. Το υπόλοιπο εξαρτάται από εσάς, τον προγραμματιστή, για να το καταλάβετε.

Ευχαριστώ για την ανάγνωση!

ουζ