DEV Community

Douglas Cardoso
Douglas Cardoso

Posted on

From Gherkin to Source Code Without Losing the Business Language

Picture this: you are a software developer building an education platform and you receive from the product owner some requirements written in Business Language (Gherkin). You need to implement these scenarios in Python.

Probably you will start creating models and service modules. You will create some classes to represent the entities described in the scenarios like Student, Course, and Subject. You will add conditionals and loops in the entity classes to control the business logic and restrict paths in the code:

# Enroll a student in a course
if course.status == "active" and student.course == None:
    student.course = course
raise BusinessError("Student already in a course")

Enter fullscreen mode Exit fullscreen mode

Also, you will create a class to represent the persistence layer (database) and methods like list_students, get_course_by_name and create_student to add, delete, update end return data from database.

You, probably, will create facades to group the classes in a logical sequence, add more if’s, else’s and loops to control the code flow.

At the end of the sprint, you have a scenario implemented and tested.

There is nothing wrong with its style of implementation. It is a common process. However, something loses the importance in this process: the business scenario itself.


In this article I’ll showcase a behavior-driven development that converts business languages directly to executable code. The intention is to keep the implementation closer to the business language and promote the code to the source-of-truth.

Gherkin Scenarios for the Education Platform

Going back to the fictional (not much) story. Here are some scenarios an education platform could have:

Feature: Student GPA and approval

  Scenario: Student is approved when GPA is 7 or higher and all subjects are passed
    Given a student named "John" is enrolled in the "Computer Science" course
    And the course has the subjects "Math", "Physics", and "Programming"
    And the student has the following grades:
      | Subject     | Grade |
      | Math        | 7     |
      | Physics     | 8     |
      | Programming | 9     |
    When the system calculates the student's GPA
    Then the GPA should be 8
    And the student status should be "Approved"

Feature: Student enrollment in course subjects

  Scenario: Student cannot enroll in a subject from another course
    Given a student named "Carlos" is enrolled in the "Medicine" course
    And the subject "Algorithms" belongs to the "Computer Science" course
    When the student tries to enroll in the subject "Algorithms"
    Then the enrollment should be rejected
    And the system should show the message "Students can only enroll in subjects from their own course"

Feature: Student enrollment in a course

  Scenario: Student enrolls in an active course
    Given a course named "Architecture" is active
    When a student named "Julia" tries to enroll in the "Architecture" course
    Then the enrollment should be accepted
    And the system should show the message "Student enrolled in course"

Feature: Course cancellation

  Scenario: Students cannot enroll in a canceled course
    Given a course named "Architecture" has been canceled by the general coordinator
    When a student named "Julia" tries to enroll in the "Architecture" course
    Then the enrollment should be rejected
    And the system should show the message "Canceled courses cannot accept new enrollments"

Enter fullscreen mode Exit fullscreen mode

They are pretty, readable, easy to understand, and find inconsistencies. Now a possible implementation as described in the previous story. It was simplified for the sake of this article. Let us look at a more traditional implementation.

# Entities
class Course:
    def __init__(self, course_id, name):
        self.course_id = course_id
        self.name = name
        self.is_canceled = False


class Student:
    def __init__(self, student_id, name):
        self.student_id = student_id
        self.name = name
        self.course = None


# Application
class UniversityService:
    def __init__(self):
        self.courses = {}
        self.students = {}

    def create_course(self, course_id, name):
        self.courses[course_id] = Course(course_id, name)

    def create_student(self, student_id, name):
        self.students[student_id] = Student(student_id, name)

    def cancel_course(self, course_id):
        course = self.courses.get(course_id)

        if course is None:
            raise ValueError("Course not found")

        course.is_canceled = True

    def enroll_student_in_course(self, student_id, course_id):
        student = self.students.get(student_id)
        course = self.courses.get(course_id)

        if student is None:
            raise ValueError("Student not found")

        if course is None:
            raise ValueError("Course not found")

        if course.is_canceled:
            raise ValueError("Canceled courses cannot accept new enrollments")

        student.course = course


# Scenario: Students cannot enroll in a canceled course
service = UniversityService()

service.create_course("C1", "Architecture")
service.create_student("S1", "Julia")
service.cancel_course("C1")

try:
    service.enroll_student_in_course("S1", "C1")
    print("Unexpected result: student enrolled in a canceled course")
except ValueError as e:
    print(e)


Enter fullscreen mode Exit fullscreen mode

It was done in a traditional style. Notice the technical references like service and the preconditions and business logic spread in many ifs in the code. We forgot to represent the system behavior in a simple and explicit way.

The scenario was spread into many pieces and it may be hard to put all of them together when we need to understand the code in the future.

Consider that more features will be integrated into the code and more if/else will be introduced to control the business logic and new flows.

In summary, the scenario cannot be read as it was presented by the business team. It is hard to validate that the system is doing what it should do without proper unit tests and careful code review.

We can try to test its integration with Python Behave to bring the explicit behavior back to the game, but it may be hard to do it without coming up against technical stuff like services.

The system works, but it is hard to prove that it behaves as expected just by reading the code.


At this point, the development team and business one are not talking the same language anymore. There is a translation from business language to production code (technical stuff).
Behavior-driven development


Now, using the framework Guará to represent the scenarios directly in the code. The code now tells the story.

For example, the scenario Student enrollment in a course can be written like this:

from guara.application import Application

eduapp = Application()

(
    eduapp.given(IsActiveCourse, course_id=course_id)
    .and_(IsNotStudentInACouse, student_id=student_id)
    .when(
        EnrollStudentInCourse,
        student_id=student_id,
        course_id=course_id,
    )
    .then(it.IsEqualTo, "Student enrolled in course")
)


Enter fullscreen mode Exit fullscreen mode

The preconditions IsActiveCourse and IsNotStudentInACourse are now explicit and are at the higher level of the code. Not buried in the methods in form of if conditionals. The precondition and action classes have single responsibilities.

from guara.transaction import AbstractTransaction

class IsActiveCourse(AbstractTransaction):
    def do(self, course_id):
        print(f"Checking the status of course {course_id}")
        status = database.courses.get_status(course_id=course_id)
        if status == "Active":
            return True
        raise CourseCanceledException("Course canceled")


class IsNotStudentInACourse(AbstractTransaction):
    def do(self, student_id):
        print(f"Checking if student in a course")
        course = database.student.get_course()
        if course:
            raise StudentException("Student already in a course")


class EnrollStudentInCourse(AbstractTransaction):
    def do(self, student_id, course_id):
        print(f"Enrolling student {student_id} in course {course_id}")
        status = database.enroll_course(course_id, student_id)
        return "Student enrolled in course"

Enter fullscreen mode Exit fullscreen mode

In the end, it is easier to compare the code against the scenario steps and assert they are present in the code.

import argparse
from guara.transaction import Application
from guara import it

eduapp = Application()

def main():
    parser = argparse.ArgumentParser()

    parser.add_argument("--action", required=True)
    parser.add_argument("--student-id")
    parser.add_argument("--course-id")

    args = parser.parse_args()

    if args.action == "enroll_course":
        try:
            (
                eduapp.given(HasCourse, course_id=args.course_id)
                .and_(IsActiveCourse, course_id=args.course_id)
                .and_(HasStudent, student_id=args.student_id)
                .and_(IsNotStudentEnrolledInCourse, student_id=args.student_id)
                .when(
                    EnrollStudentInCourse,
                    student_id=args.student_id,
                    course_id=args.course_id,
                )
                .asserts(it.IsTrue)
            )
        except Exception as e:
            print(str(e))
            app.undo()

# Calling the CLI            
python edu.py enroll-course --course-id 10 --student-id 1324

Enter fullscreen mode Exit fullscreen mode

Benefits

  • The production code is now the source of truth
  • It can be compared directly to the business scenarios
  • The responsibilities are encapsulated in dedicated classes
  • It is possible to undo operations easily once the framework is based on the Command Pattern (GoF)
  • It is easy to add more behavior to the code without changing other classes
  • The classes are reusable
  • It hides the technical stuff. They still exist, but now the actions are first-class citizens

Points of attention

  • It is not a one-size-fits-all style. It is necessary to evaluate if the system under development will benefit from this code style
  • Makes more sense when the scenarios are defined in Gherkin language; otherwise, it will be necessary to translate the requirement to code as done in the traditional implementation

Conclusion

The important difference is that the source code still reads almost like the original Gherkin scenario. Instead of hiding business rules inside technical layers, we keep them visible and explicit in the code.

Top comments (0)