NELKINDA SOFTWARE CRAFT

BDD vs BDD-Style

Just because of describing the expected behavior of a system in Cucumber Feature Files doesn't make it proper Behavior-Driven Development. Proper BDD is abstract and agnostic of implementation details or interfaces.

Author:
Christian Hujer, CEO / CTO at Nelkinda Software Craft Pvt Ltd
First Published:
by NNelkinda Software Craft Private Limited
Last Modified:
by Christian Hujer
Approximate reading time:

1 BDD-Style

I often see people claiming to perform BDD because they write feature files and use a framework like Cucumber to execute them. Their feature files look like this:

Feature: Login
  Background:
    Given a user "testuser" with password "testpassword" exists

  Scenario: Successful Login
    Given I have all cookies cleared
    And I go to the start page
    When I enter "testuser" in the field "username"
    And I enter "testpassword" in the field "password"
    And I hit the "login" button
    Then the HTTP response code MUST be 200 "Ok"
    And the response MUST set a cookie with the JWT
Listing 1-1: Listing: LoginUi.feature

While this looks like BDD at first glance, it's not real BDD. The test does not describe the behavior of the system, but its user interface. Such tests could possibly still be useful tests. But this is not proper BDD, and thus it shouldn't be called like that. I like to call such tests BDD-style.

2 "Proper" BDD

This is how the same feature looks like when expressed as a real BDD feature file:

Feature: Login
  Background:
    Given a user "testuser" with password "testpassword" exists

  Scenario:
    Given I am currently not logged in
    When logging in with username "testuser" and password "testpassword"
    Then login MUST be successful
Listing 2-1: Listing: Login.feature

Observe that the feature file does not have any "solutionized" elements. It does not mention any interface. It is agnostic of the technology.

Just like the other feature file, this can still be run as an end-to-end user interface test. But it can also be run as a REST-API test. Or as a unit test.

2.1 End-to-End User Interface Glue

We can run this feature file as an end-to-end user interface test. For that, we could, for example, use Selenium WebDriver.

public class UiStepDefs {
    @LocalPort private int port;
    @Autowired private UserService userService;
    private WebDriver webDriver;

    @Before
    public void setupWebDriver() {
        WebDriverManager.chromedriver().setup();
        final ChromeOptions options = new ChromeOptions();
        options.addArguments("headless");
        webDriver = new ChromeDriver(options);
    }

    @After
    public void closeDriver() {
        webDriver.quit();
    }

    @Given("a user {string} with password {string} exists")
    public void createUser(final String username, final String password) {
        userService.addUser(username, password);
    }

    @Given("I am currently not logged in")
    public void notLoggedIn() {
        webDriver.manage().deleteAllCookies();
        webDriver.get("http://localhost:" + port);
    }

    @When("logging in with username {string} and password {string}")
    public void login(final String username, final String password) {
        webDriver.findElement(By.id("username")).sendKeys(username);
        webDriver.findElement(By.id("password")).sendKeys(password);
        webDriver.findElement(By.id("login")).click();
    }

    @Then("login MUST be successful")
    public void assertLoginSuccess() {
        assertNotNull(webDriver.manage().getCookieNamed("JWT"));
    }
}
Listing 2-2: Listing: UiStepDefs.java
class UiStepDefs(
    @LocalPort private val port = 0,
    @Autowired private val userService: UserService? = null,
) {
    private var webDriver: WebDriver? = null

    @Before
    fun setupWebDriver() {
        WebDriverManager.chromedriver().setup()
        webDriver = ChromeDriver(ChromeOptions().apply {
            addArguments("headless")
        })
    }

    @After
    fun closeDriver() {
        webDriver.quit()
    }

    @Given("a user {string} with password {string} exists")
    fun createUser(username: String?, password: String?) {
        userService.addUser(username, password)
    }

    @Given("I am currently not logged in")
    fun notLoggedIn() {
        webDriver.manage().deleteAllCookies()
        webDriver.get("http://localhost:$port")
    }

    @When("logging in with username {string} and password {string}")
    fun login(username: String?, password: String?) {
        webDriver.findElement(By.id("username")).sendKeys(username)
        webDriver.findElement(By.id("password")).sendKeys(password)
        webDriver.findElement(By.id("login")).click()
    }

    @Then("login MUST be successful")
    fun assertLoginSuccess() {
        assertNotNull(webDriver.manage().getCookieNamed("JWT"))
    }
}
Listing 2-3: Listing: UiStepDefs.kt

2.2 Integration Test Glue

We can run the very same feature file as an integration test. For that, we could, for example, use an HTTP client.

public class ApiStepDefs {
    @Autowired private TestRestTemplate testRestTemplate;
    private Response response;

    @Given("a user {string} with password {string} exists")
    public void createUser(final String username, final String password) {
        userService.addUser(username, password);
    }

    @Given("I am currently not logged in")
    public void notLoggedIn() {
        // Nothing to do
    }

    @When("logging in with username {string} and password {string}")
    public void login(final String username, final String password) {
        response = testRestTemplate.postForEntity("/session", null, Object.class);
    }

    @Then("login MUST be successful")
    public void assertLoginSuccess() {
        assertEquals(HttpStatus.OK, response.getStatusCode());
    }
}
Listing 2-4: Listing: ApiStepDefs.java
class ApiStepDefs(
    @Autowired private val testRestTemplate: TestRestTemplate,
) {
    private var response: Response? = null

    @Given("a user {string} with password {string} exists")
    fun createUser(username: String, password: String) {
        userService.addUser(username, password)
    }

    @Given("I am currently not logged in")
    fun notLoggedIn() {
        // Nothing to do
    }

    @When("logging in with username {string} and password {string}")
    fun login(username: String, password: String) {
        response = testRestTemplate.postForEntity<Any>("/session", null)
    }

    @Then("login MUST be successful")
    fun assertLoginSuccess() =
        assertEquals(HttpStatus.OK, response.getStatusCode())
}
Listing 2-5: Listing: ApiStepDefs.kt

2.3 Unit Test Glue

And we can run the very same feature file as a unit test (sic!). For that, we directly create the object under test, mocking external dependencies, and directly call its methods.

public class UnitStepDefs {
    private UserRepository userRepository = Mockito.mock(UserRepository.class);
    private SessionRepository sessionRepository = Mockito.mock(SessionRepository.class);

    private SessionService sessionService = new SessionService(userRepository);
    private Optional<Session> session;

    @Given("a user {string} with password {string} exists")
    public void createUser(final String username, final String password) {
        Mockito.when(userRepository.findByUsername(username))
                .thenReturn(new User(username, Bcrypt.encrypt(password)));
    }

    @Given("I am currently not logged in")
    public void notLoggedIn() {
        // Nothing to do
    }

    @When("logging in with username {string} and password {string}")
    public void login(final String username, final String password) {
        session = sessionService.login(username, password);
    }

    @Then("login MUST be successful")
    public void assertLoginSuccess() {
        assertTrue(session.isPresent());
    }
}
Listing 2-6: Listing: UnitStepDefs.java
class UnitStepDefs {
    private val userRepository = mock<UserRepository>()
    private val sessionRepository = mock<SessionRepository>
    private val sessionService = SessionService(userRepository)

    private var session: Optional<Session>? = null

    @Given("a user {string} with password {string} exists")
    fun createUser(username: String, password: String) {
        `when`(userRepository.findByUsername(username))
            .thenReturn(User(username, Bcrypt.encrypt(password)))
    }

    @Given("I am currently not logged in")
    fun notLoggedIn() {
        // Nothing to do
    }

    @When("logging in with username {string} and password {string}")
    fun login(username: String, password: String) {
        session = sessionService.login(username, password)
    }

    @Then("login MUST be successful")
    fun assertLoginSuccess() = assertTrue(session.isPresent())
}
Listing 2-7: Listing: UnitStepDefs.kt

2.4 Explanation

A key thing is to describe the behavior without bringing any details about the solution like architectural interface into the test. That knowledge is reserved for the step definition. You know you have achieved that if you can run the same feature file with different step definitions to verify the behavior on different layers of the test pyramid.

3 P.S.: Caveat about Login

The example above uses Login as the feature to be verified. Most of us know login situations and will immediately understand the context. That makes login a good example to explain this aspect of BDD. However, let me point out this: For most systems, "Login" should be even more abstract, and definitely not be a user story. But that's a story for another blog article.

4 P.P.S.: "BDD-Style"?

I believe that the phrase "BDD-Style" doesn't fully communicate that this is usually not the way to go. I'm therefore looking for phrases that communicate this intent better. Candidates are "Faux-BDD" and "Pseudo-BDD", and I'm open for suggestions.