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")
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"
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)
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")
)
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"
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
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)