On Github velesin / bdd-course
by Wojciech Zawistowski
after the code
vs
before the code (TDD)
"classic" TDD
vs
Behavior Driven Development (BDD)
focus on code (methods)
describe "DartGame.scorePoints", -> ... describe "DartGame.isWin", -> ...
describe "DartGame.scorePoints", -> beforeEach -> game.setPoints 10 it "updates points when scoring", -> game.scorePoints 9 expect(game.getPoints).toEqual 1 it "updates points when scoring all the points left", -> game.scorePoints 10 expect(game.getPoints).toEqual 0 it "does not update points when overscoring", -> game.scorePoints 11 expect(game.getPoints).toEqual 10
describe "DartGame.isWin", -> it "returns false when score is greater than zero", -> game.setPoints 1 expect(game.isWin).toBeFalse it "returns true when score equals zero", -> game.setPoints 0 expect(game.isWin).toBeTrue
it "updates points when scoring all the points left", -> game.scorePoints 10 expect(game.getPoints).toEqual 0
Do we really care if points are zeroed?Or only if the game is marked as won?
it "returns true when score equals zero", -> game.setPoints 0 expect(game.isWin).toBeTrue
Do we really care if the game is won when points EQUAL zero?Or rather when they REACH zero?
Is it realistic scenario to win the game by SETTING the points?Or rather should the game be won by SCORING proper number of points?
How to find all the legal "moves" in a game?Simple cross section of all scorePoints tests with all isWin tests gives also some obviously invalid combinations... To find only the valid ones you need to reverse engineer the tests.
focus on domain (game flow)
describe "Dart game: scoring", -> ... describe "Dart game: overscoring", -> ... describe "Dart game: scoring exactly", -> ...
describe "Dart game: scoring", -> beforeEach -> game.setPoints 10 game.scorePoints 9 it "updates points", -> expect(game.getPoints).toEqual 1 it "does not result in a win yet", -> expect(game).not.toBeWin
describe "Dart game: overscoring", -> beforeEach -> game.setPoints 10 game.scorePoints 11 it "does not update points", -> expect(game.getPoints).toEqual 10 it "does not result in a win", -> expect(game).not.toBeWin
describe "Dart game: scoring exactly", -> beforeEach -> game.setPoints 10 game.scorePoints 10 it "results in a win", -> expect(game).toBeWin
describe "Dart game: scoring exactly", -> it "results in a win", -> expect(game).toBeWin # there is no test related to updating points!
We don't care if points are zeroed or not in a win conditionOf course we could IF there are such business requirements (but no guessing!)
describe "Dart game: scoring exactly", -> beforeEach -> ... game.scorePoints 10 it "results in a win", -> ...
We check realistic win condition: scoring exactly(not setting the points "by hand")
How to find all the legal "moves" in a game?By straightforward reading the test...
describe "Dart game: scoring", -> describe "Dart game: overscoring", -> describe "Dart game: scoring exactly", ->
First little bit of a test
# the test describe "User", -> it "provides name and surname parts of full name", -> user = new User
It's only the test setup, no expectations yet, but we still pause here and run the test...
...and the test fails - no User class yet.
Now some code to make it pass
# the test describe "User", -> it "provides name and surname parts of full name", -> user = new User
#the code class User
Yep, just that. Now the test passes.
Now we exercise the User
# the test describe "User", -> it "provides name and surname parts of full name", -> user = new User user.setFullName "Wojciech Zawistowski"
#the code class User
Test fails - no setFullName method.
Minimal possible step to make test pass
# the test describe "User", -> it "provides name and surname parts of full name", -> user = new User user.setFullName "Wojciech Zawistowski"
#the code class User setFullName: (fullName) ->
Yes, method does nothing for now, not even assigns to a var.
Finally, actual expectations
# the test describe "User", -> it "provides name and surname parts of full name", -> user = new User user.setFullName "Wojciech Zawistowski" expect(user.name).toEqual "Wojciech" expect(user.surname).toEqual "Zawistowski"
#the code class User setFullName: (fullName) ->
And finally a business logic related test fail.
Minimal step to make it pass
# the test describe "User", -> it "provides name and surname parts of full name", -> user = new User user.setFullName "Wojciech Zawistowski" expect(user.name).toEqual "Wojciech" expect(user.surname).toEqual "Zawistowski"
#the code class User setFullName: (fullName) -> name: -> "Wojciech" surname: -> "Zawistowski"
Yes, hardcoded! It seems dumb, but it proves that the test works.
And the second test
# the test describe "User", -> it "provides name and surname parts of full name", -> user = new User user.setFullName "Wojciech Zawistowski" expect(user.name).toEqual "Wojciech" expect(user.surname).toEqual "Zawistowski" # it works also when full name is updated user.setFullName "Chuck Norris" expect(user.name).toEqual "Chuck" expect(user.surname).toEqual "Norris"
Very ugly copy paste (but it's the easiest thing to do for now).
We make it pass
#the code class User setFullName: (fullName) -> fullNameParts = fullName.split(" ") @name = fullName[0] @surname = fullName[1] name: -> @name surname: -> @surname
Finally, real business logic!
Now it's time to clean up the (UGLY!) test(remember red, green, REFACTOR?)
# the test describe "User: accessing name and surname parts of full name", -> beforeEach -> user = new User user.setFullName "Wojciech Zawistowski" it "works for initially set full name", -> expect(user.name).toEqual "Wojciech" expect(user.surname).toEqual "Zawistowski" it "works for updated full name" user.setFullName "Chuck Norris" expect(user.name).toEqual "Chuck" expect(user.surname).toEqual "Norris"
Remove all duplication etc. - the same as in "real" code.
we've seen only the test refactoring, but for code it's the same:
e.g. to add param validation to setFullName, we would:
bad
test_invoice_email() {...}
better
test_that_invoice_email_is_sent_when_invoice_is_issued() {...}
bad
test_that_logIn_returns_null_when_moderator_flag_is_true() {...}
better
test_that_user_banned_by_moderator_cannot_log_in() {...}
bad
user = User.logIn "qweert", "abcdefgh" expect(user).not.toBeLoggedIn
better
validLogin = "qweert" invalidPassword = "abcdefgh" user = User.logIn validLogin, invalidPassword expect(user).not.toBeLoggedIn
even better
validLogin = "valid_username" invalidPassword = "pw_without_at_least_one_digit" unauthorizedUser = User.logIn validLogin, invalidPassword expect(unauthorizedUser).not.toBeLoggedIn
bad
user = new User name: "Irrelevant Name" address: "Some Irrelevant Address" isActive: true post = new Post author: user text: "some irrelevant text" post.publish() post.flagAsSpam() expect(user.isActive).toBeFalse
better
user = createActiveUser() # factory hiding irrelevant user creation details publishSpam(user) # helper method hiding irrelevant post flagging details expect(user).toBeDeactivated # custom assertion hiding irr. status details
every test can be reduced to these 3 distinct phases:
at least separate these 3 phases visually with whitespace
user.logIn visit "/profile" fillIn ".new-post", "some text" click ".submit-post" visit "/recent-posts" post = findFirst ".post" expect(post.text).toEqual "some text"
or be hardcore and reduce them to meaningful one-liners
given_user_is_logged_in_to_profile_page when_user_creates_new_post then_new_post_is_published_at_the_top_of_recent_posts_list
for low-level unit tests this is often an overkill:
given_empty_array_exists when_new_item_is_added #vs array = [] array.push "new item"
but for more complicated tests (esp. e2e acceptance tests)
when_user_registers_an_account #vs visit "/sign-up" fillIn ".login", "some_login" fillIn ".password", "some_pwd" fillIn ".confirm-password", "some_pwd" click ".submit" visit "/confirm-signup" click ".confirmation-link"
makes a big difference
The RED step from red, green, refactor is also for verification if your failure messages are clear.
bad
assertTrue(user.isAdmin) # fail message: # expected true but got false
better
assertTrue(user.isAdmin, "user is not an admin") # fail message: # user is not an admin
even better
assertTrue(user.isAdmin, "user in group #{user.group} is not an admin) # fail message: # user in group 'moderators' is not an admin
assertTrue(user.isAdmin, "user in group #{user.group} is not an admin) # vs assertAdmin(user)
bad
describe "new user", -> it "is not activated and has empty account", -> expect(user).not.toBeActivated expect(user.accountBalance).toEqual 0 # the alarm signal: # difficult to name the test without using ANDs or generalizations
better
describe "new user", -> it "is not activated", -> expect(user).not.toBeActivated it "has empty account", -> expect(user.accountBalance).toEqual 0
good
describe "guest user", -> it "has empty address", -> expect(user.address).toBeEmpty it "has empty phone number", -> expect(user.phoneNumber).toBeEmpty
even better
describe "guest user", -> it "has empty contact data", -> expect(user.address).toBeEmpty expect(user.phoneNumber).toBeEmpty # assertions grouped under a single meaningful concern ("contact data")
Depends also how the test framework shows fail messages:
(You must decide what's more debuggable and group / distribute assertions accordingly)
debugging is for production bugs(if you have to debug to find why a test failed, you're doing it wrong!!!)
higher level (requirements instead of code) but the same principles apply
no false alarms(if a test reports a fail it must mean code is broken, not that the DB connectoin is slow or network is down)
Tests support modification of existing features, therefore:
Tests must be constantly maintained (refactored etc.) the same as "normal" production code.
the same as for any other code (design paterns, code smells etc.)
dependency injection + test doubles (stubs, mocks, spies)
#tested class class Invoice issueNumber: -> currentDate = new Date #test it "issues correct number for 31 Dec", -> #no way to test it...
vs
#tested class class Invoice constructor: (dateFactory) -> @dateFactory = dateFactory issueNumber: -> currentDate = @dateFactory.currenDate #test it "issues correct number for 31 Dec", -> dateFactoryStub = stub(DateFactory) dateFactoryStub.currentDate.returns(new Date("2000-12-31")) invoice = new Invoice(dateFactoryStub)
stubs
describe "FileConverter", -> it "uppercases file contents", -> fileReaderStub = stub(FileReader) fileReaderStub.read.returns("some text") converter = new FileConverter(fileReaderStub) expect(converter.uppercased).toEqual "SOME TEXT"
spies
describe "AlertPrinter", -> it "sends alert message to a printer", -> printDriverSpy = spy(PrintDriver) alertPrinter = new AlertPrinter(printDriverSpy) alertPrinter.sendAlert "some text" expect(printDriverSpy.print).toHaveBeenCalledWith "some text"
mocks
describe "AlertPrinter", -> it "sends alert message to a printer", -> printDriverMock = mock(PrintDriver) expect(printDriverMock.print).toBeCalledWith "some text" alertPrinter = new AlertPrinter(printDriverMock) alertPrinter.sendAlert "some text"
spies
describe "AlertPrinter", -> it "sends alert message to a printer", -> printDriverSpy = spy(PrintDriver) alertPrinter = new AlertPrinter(printDriverSpy) alertPrinter.sendAlert "some text" expect(printDriverSpy.print).toHaveBeenCalledWith "some text"
ok (you can't avoid it)
fileReaderStub.read.withParam("file_1.txt").returns "text 1" fileReaderStub.read.withParam("file_2.txt").returns "text 2" expect(concatenator.concat("file_1", "file_2")).toEqual "text 1 text 2"
bad
fileReaderStub.read.withParam("some_file.txt").returns "some text" expect(formatter.uppercase("some_file")).toEqual "SOME TEXT" # in this test we should verify uppercasing, not reading correct file!
better
# returns "some text" for ANY file - it doesn't matter for this test fileReaderStub.read.returns "some text" expect(formatter.uppercase("some_file")).toEqual "SOME TEXT"
ok (this is the goal of this test)
it "sends correct mail", -> user.sendAlertMail expect(mailerMock.send).toHaveBeenCalledWith "alert!!!"
bad
it "blocks duplicate alerts from the same user", -> user.sendAlertMail user.sendAlertMail expect(mailerMock.send).toHaveBeenCalledOnce.with "alert!!!" # the goal of this test is to check duplication, not the content
better
it "blocks duplicate alerts from the same user", -> user.sendAlertMail user.sendAlertMail expect(mailerMock.send).toHaveBeenCalledOnce
bad
it "sends correct alert mail", -> expect(mailerMock.send).toBeCalledWith("text").andReturn "ok" user.sendAlertMail expect(user.lastAlertStatus).toEqual "ok" # tempting, but it hides second concern in a single test method
better
it "sends correct alert mail", -> expect(mailerMock.send).toBeCalledWith("text") user.sendAlertMail it "stores last alert status code", -> mailerStub.returns "ok" user.sendAlertMail expect(user.lastAlertStatus).toEqual "ok"
sendAlertMail method may crash without status code...
...in such case we should give it any (simplest possible) code:
it "sends correct alert mail", -> expect(mailerMock.send).toBeCalledWith("text").andReturn "whatever" user.sendAlertMail # and we shouldn't exercise this code with any assertions here!!! # only here, in separate test, like in previous example... it "stores last alert status code", -> mailerStub.returns "ok" user.sendAlertMail expect(user.lastAlertStatus).toEqual "ok"
bad
userStub.getBooking.returns bookingStub bookingStub.getHotel.returns hotelStub hotelStub.getName.returns "Some Hotel" mail = new WelcomeBackMail(userStub) expect(mail.title).toEqual "Welcome back from Some Hotel!"
better
userStub.getLastBookingHotelName.returns "Some Hotel" mail = new WelcomeBackMail(userStub) expect(mail.title).toEqual "Welcome back from Some Hotel!"
(less brittle test and will force better code design)
ok
phoneNumber = new PhoneNumber(prefix: 123, number: 4567890) user = new User(phoneNumber)
overkill
phoneNumberStub = stub(PhoneNumber) phoneNumberStub.prefix.returns 123 phoneNumberStub.number.returns 4567890 user = new User(phoneNumberStub)
bad (slow tests)
class Invoice addItem: (item) -> if self.validate(item) self.itemCollection.add(item) self.save() # always hits the DB (on each validation related test branch)
better
class Invoice addItem: (item) -> if self.validate(item) self.itemCollection.add(item) class InvoiceRepository saveInvoice: (invoice) -> #... # adding item to an invoice and saving an invoice are separate concerns # (adding item = unit tests, saving invoice = integration test)
Never stub or mock methods of the class you're testing!!!(If it seems necessary, you most probably should extract part of the class)
Tests should have short and simple setup.(If you have to create too many or nested stubs, consider redesigning your code!)
What do you mean by merging the test into other tests?
When implementing higher layer code:
some tests may be closer, some must be farther from the refactored code
Yes, creating such safety net is a lot of work...
...that will be thrown away :( ...
...but refactoring without it is much more work (and risk)!
Just try to instantiate a class and see why the test breaks.(what other classes it requires)
Set nonsensical assertions and find real value in error message.
But a good starting point for discussion with your analyst ;)