Table of contents
- Understanding Specification-Driven Development
- From TDD to BDD to Specification-Driven Development
- The Double-Loop: Outside-In with Specifications
- Specifications vs. Traditional Tests
- Tools and Frameworks for Specification-Driven Development
- Living Documentation
- Practical Workflow
- Benefits of Specification-Driven Development
- Challenges and Considerations
- When to Use Specification-Driven Development
- Integrating with TDD Practices
- Related Practices
- Practical Example: Building a Library System
- Conclusion
- References
- Additional Resources
Specification-Driven Development
The content here is under the Attribution 4.0 International (CC BY 4.0) license
Specification-Driven Development (SDD) represents a natural evolution in the test-first philosophy, extending the principles of Test-Driven Development (TDD) by emphasizing specifications as the primary driver of software design. While TDD focuses on writing tests before code, SDD takes this further by treating tests as executable specifications that serve as both validation and documentation.
A space dedicated for Test-Driven Development
This blog hosts a dedicated space for TDD-related content, where you can find posts that explore the concept of TDD, its benefits, and how it can be effectively implemented in software development workflows.
Understanding Specification-Driven Development
Specification-Driven Development is not a replacement for TDD but rather a refinement of how we think about tests. The core idea is that tests should be written as specifications of behavior, describing what the system should do rather than how it does it. This subtle shift in perspective has consequences for how we design, document, and communicate about software.
(Beck, 2002) introduced the concept of tests as specifications when he described TDD’s role in clarifying requirements. The gap between decision and feedback in programming extends beyond just technical validation. It includes the gap between what stakeholders need and what developers build.
The term “specification” in this context refers to a description of expected behavior that is:
- Executable: Can be run to verify the system works as specified
- Readable: Written in a way that non-technical stakeholders can understand (or at least review)
- Maintainable: Evolves with the system as requirements change
- Precise: Unambiguous about what behavior is expected
From TDD to BDD to Specification-Driven Development
The evolution from TDD to specification-driven approaches follows a logical progression in the software industry’s understanding of test-first development.
Test-Driven Development (TDD)
TDD introduced the Red-Green-Refactor cycle (Beck, 2002):
- Write a failing test (Red)
- Write minimal code to make it pass (Green)
- Refactor to improve design (Refactor)
This cycle works at the unit level, focusing on individual components and their interactions. TDD improved code quality by ensuring testability and providing rapid feedback. However, unit tests written in TDD often used technical language and focused on implementation details, making them less accessible to non-developers.
The foundations of TDD
If you are new to TDD, I recommend starting with A Gentle Introduction to TDD, which covers the fundamentals of the practice and explains the Red-Green-Refactor cycle in detail.
Behavior-Driven Development (BDD)
Dan North introduced Behavior-Driven Development around 2006 to address communication gaps in TDD (North, 2006). BDD emphasized:
- Using domain language in tests
- Focusing on behavior rather than structure
- Collaborating with stakeholders to define acceptance criteria
- Using Given-When-Then format for scenarios
BDD introduced tools like Cucumber (Wynne & Hellesoy, 2012) and SpecFlow that allowed writing specifications in natural language. These specifications could be executed as tests, bridging the communication gap between technical and non-technical team members.
Specification-Driven Development
Specification-Driven Development builds on both TDD and BDD by treating all tests (not just acceptance tests) as specifications. It emphasizes that specifications should:
- Focus on observable behavior
- Avoid implementation details
- Serve as documentation
- Guide design decisions
- Facilitate communication
The key distinction is that SDD applies the specification mindset throughout the development process, from acceptance criteria down to unit-level behavior. This creates a coherent documentation system where each level of specification provides appropriate detail for its audience.
The Double-Loop: Outside-In with Specifications
Specification-Driven Development aligns naturally with the outside-in TDD approach (Freeman & Pryce, 2009). The double-loop provides two levels of specification:
Outer Loop: Acceptance Specifications
The outer loop starts with high-level specifications that describe features from the user’s perspective. These specifications:
- Define acceptance criteria
- Use business language
- Describe end-to-end behavior
- Validate the system from the outside
Getting started with outside-in TDD
Setting up an environment for outside-in TDD requires proper tooling. The post Crafting a Solid Foundation with Outside-In TDD provides a practical guide to setting up Cypress and Testing Library for this approach.
Inner Loop: Unit Specifications
The inner loop contains specifications for individual components. These specifications:
- Define component behavior
- Use technical language (but still focus on behavior)
- Describe interactions and contracts
- Enable refactoring with confidence
The relationship between these loops creates a documentation hierarchy. Acceptance specifications answer “what should the system do?”, while unit specifications answer “how do components collaborate to achieve this?”.
(Chelimsky et al., 2010) describes this relationship in the context of RSpec and Cucumber:
This is the BDD cycle. Driving development from the outside in, starting with business-facing scenarios in Cucumber and working our way inward to the underlying objects with RSpec.
Specifications vs. Traditional Tests
The distinction between specifications and traditional tests is primarily conceptual, but this conceptual shift has practical implications.
Traditional Test Characteristics
Traditional tests often:
- Focus on code structure (testing private methods, internal state)
- Use technical jargon
- Couple to implementation details
- Break when refactoring, even if behavior is unchanged
- Serve primarily as a safety net
Specification Characteristics
Specifications, by contrast:
- Focus on observable behavior
- Use domain language where possible
- Decouple from implementation details
- Remain stable during refactoring (if behavior is unchanged)
- Serve as both validation and documentation
Consider this traditional test:
describe('UserRepository', () => {
it('should call the database with correct SQL', () => {
const mockDb = createMockDatabase();
const repo = new UserRepository(mockDb);
repo.save({ id: 1, name: 'Alice' });
expect(mockDb.execute).toHaveBeenCalledWith(
'INSERT INTO users (id, name) VALUES (?, ?)',
[1, 'Alice']
);
});
});
This test couples to the implementation (the exact SQL query). If we refactor to use an ORM, the test breaks even though the behavior (saving a user) hasn’t changed.
Compare this with a specification:
describe('UserRepository', () => {
it('persists users so they can be retrieved later', () => {
const repo = new UserRepository(database);
const user = { id: 1, name: 'Alice' };
repo.save(user);
const retrieved = repo.findById(1);
expect(retrieved).toEqual(user);
});
});
This specification describes the observable behavior (persistence) without coupling to implementation details. It remains valid whether we use SQL, an ORM, or even switch to NoSQL.
Tools and Frameworks for Specification-Driven Development
Different ecosystems have evolved different tools to support specification-driven approaches. These tools generally fall into two categories: those for acceptance-level specifications and those for unit-level specifications.
Acceptance-Level Specification Tools
Cucumber (Wynne & Hellesoy, 2012)
Cucumber uses Gherkin syntax to write specifications in natural language:
Feature: User Authentication
As a user
I want to log in to my account
So that I can access my personalized dashboard
Scenario: Successful login with valid credentials
Given I am on the login page
When I enter my username "alice@example.com"
And I enter my password "securePassword123"
And I click the "Login" button
Then I should see my personalized dashboard
And I should see a welcome message "Welcome, Alice"
These scenarios are executable through step definitions that map natural language to code.
SpecFlow
SpecFlow brings Cucumber-style specifications to the .NET ecosystem. It integrates with testing frameworks like NUnit and xUnit, allowing teams to write specifications in Gherkin and execute them within their existing test infrastructure.
Gauge
Gauge is another tool for writing executable specifications in markdown format. It emphasizes readability and supports multiple programming languages, making it suitable for organizations working in polyglot environments.
Unit-Level Specification Tools
RSpec
RSpec pioneered the specification style for unit tests in Ruby (Chelimsky et al., 2010):
RSpec.describe Calculator do
describe '#add' do
it 'returns the sum of two numbers' do
calculator = Calculator.new
expect(calculator.add(2, 3)).to eq(5)
end
end
end
The describe and it blocks create readable specifications, and RSpec’s extensive matcher library helps write expressive assertions.
Jasmine and Jest
Jasmine brought RSpec’s style to JavaScript, and Jest built upon this foundation:
describe('Calculator', () => {
describe('add', () => {
it('returns the sum of two numbers', () => {
const calculator = new Calculator();
expect(calculator.add(2, 3)).toBe(5);
});
});
});
Testing Library
Testing Library (Dodds, 2018) takes specification-driven thinking further by enforcing tests that interact with components as users would:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('LoginForm', () => {
it('submits credentials when user fills form and clicks submit', async () => {
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await userEvent.type(screen.getByLabelText('Email'), 'alice@example.com');
await userEvent.type(screen.getByLabelText('Password'), 'secret123');
await userEvent.click(screen.getByRole('button', { name: 'Log in' }));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'secret123'
});
});
});
This approach inherently creates specifications that focus on behavior rather than implementation.
Living Documentation
One of the most valuable aspects of Specification-Driven Development is that specifications serve as living documentation (Parnas, 1994). Unlike traditional documentation, which quickly becomes outdated, specifications remain accurate because they are executable.
Documentation at Multiple Levels
Specifications create documentation at different levels of abstraction:
System Level: Acceptance specifications document features and user journeys. They answer questions like:
- What can users do with the system?
- What are the business rules?
- How do different features work together?
Component Level: Unit specifications document component behavior. They answer questions like:
- What does this component do?
- How does it handle edge cases?
- What are its dependencies?
Integration Level: Integration specifications document how components collaborate. They answer questions like:
- How do these components interact?
- What data flows between them?
- What happens when interactions fail?
Documentation Maintenance
Traditional documentation requires manual maintenance, which is often neglected. Specifications, being executable, must be maintained or they fail. This creates a natural pressure to keep documentation current.
When requirements change:
- Specifications are updated to reflect new behavior
- Tests fail, indicating implementation needs updating
- Code is changed to make specifications pass
- Documentation is automatically current
This cycle ensures documentation accuracy without additional process overhead.
Stakeholder Communication
Specifications improve communication with stakeholders in several ways:
Shared Understanding: Writing specifications collaboratively (through techniques like Example Mapping or Specification by Example (Adzic, 2011)) ensures shared understanding before development begins.
Progress Visibility: Passing specifications provide tangible evidence of progress. Stakeholders can see which features are complete and working.
Change Impact: When requirements change, specifications make the impact visible. Teams can see which specifications need updating and estimate the effort required.
Practical Workflow
Implementing Specification-Driven Development follows a structured workflow that integrates with existing agile and continuous delivery practices.
1. Discover and Define
Before writing code, collaborate with stakeholders to discover requirements:
Example Mapping: Use example mapping sessions (Matts, 2009) to explore features:
- User story at the top
- Rules below the story
- Examples below each rule
- Questions captured separately
Specification by Example: Work through concrete examples of how features should behave. Turn these examples into acceptance specifications.
2. Write Acceptance Specifications
Start with the outer loop. Write acceptance specifications that describe the feature from the user’s perspective:
Feature: Shopping Cart
Scenario: Adding items increases total price
Given the shopping cart is empty
When I add a product priced at $10
And I add a product priced at $15
Then the cart total should be $25
Run the specification. It will fail because the feature is not implemented yet (Red phase).
3. Write Unit Specifications
Drop into the inner loop. Write specifications for the components needed to implement the feature:
describe('ShoppingCart', () => {
describe('addItem', () => {
it('increases the total by the item price', () => {
const cart = new ShoppingCart();
cart.addItem({ price: 10 });
expect(cart.getTotal()).toBe(10);
});
});
});
Implement the component to make the specification pass (Green phase).
4. Refactor
With both acceptance and unit specifications passing, refactor to improve design. Specifications provide confidence that behavior is preserved during refactoring.
5. Iterate
Continue the cycle, using specifications to guide development at both levels. The acceptance specification drives what to build, while unit specifications drive how to build it.
Benefits of Specification-Driven Development
Specification-Driven Development provides several benefits that extend beyond those of traditional TDD.
Improved Communication
Specifications using domain language create a shared vocabulary between developers, testers, product owners, and other stakeholders. This shared language reduces misunderstandings and ensures everyone has the same understanding of what the system should do.
Living Documentation
Specifications that are always current eliminate the documentation drift problem. Teams can confidently refer to specifications knowing they accurately describe the system’s behavior.
Better Design
Thinking in terms of specifications encourages thinking about behavior and contracts rather than implementation details. This leads to designs with better separation of concerns and clearer interfaces.
Confident Refactoring
Specifications that focus on behavior rather than implementation provide a safety net during refactoring. As long as specifications pass, you can be confident that behavior is preserved, even when internal structure changes substantially.
Regression Prevention
Like any test-first approach, specifications catch regressions early. The difference is that specification failures provide more meaningful error messages because they describe expected behavior rather than technical assertions.
Onboarding and Knowledge Transfer
New team members can read specifications to understand what the system does and how it behaves. Specifications serve as both documentation and learning material, reducing onboarding time.
Challenges and Considerations
While Specification-Driven Development offers benefits in communication, documentation, and design quality, it also presents challenges that teams should consider.
Learning Curve
Shifting from traditional testing to specification-driven thinking requires practice. Developers must learn to:
- Write specifications at appropriate abstraction levels
- Focus on behavior rather than implementation
- Use domain language effectively
- Balance specification detail with maintainability
This learning curve can initially slow development as teams adapt to the new approach.
Tool Complexity
Specification tools like Cucumber introduce additional complexity. Teams must:
- Maintain step definitions
- Manage test data
- Handle asynchronous operations
- Debug through multiple layers
This complexity can be overwhelming for teams new to the approach. It is worth considering whether the benefits of natural-language specifications justify this complexity for your context.
Specification Maintenance
While specifications serve as living documentation, they still require maintenance. As the system evolves:
- Specifications need updating to reflect new behavior
- Obsolete specifications must be removed
- Similar scenarios may need consolidation
Teams must dedicate time to specification maintenance, just as they would to any test suite.
Over-Specification
There is a risk of over-specifying behavior, creating brittle tests that break when implementation details change. This happens when specifications:
- Describe internal state
- Depend on specific implementation choices
- Test multiple concerns in a single specification
- Focus on “how” rather than “what”
Avoiding over-specification requires discipline and experience.
Performance Considerations
Acceptance specifications that exercise the full system can be slow. This impacts:
- Developer feedback cycles
- Continuous integration build times
- Overall development velocity
Teams must balance comprehensive specification coverage with acceptable execution times. Strategies include:
- Running subsets of specifications frequently
- Optimizing slow specifications
- Using test doubles judiciously
- Parallelizing specification execution
When to Use Specification-Driven Development
Specification-Driven Development is not universally applicable. Consider using it when:
Communication with Stakeholders is Critical
If your project involves close collaboration with non-technical stakeholders (product owners, domain experts, compliance officers), SDD’s focus on readable specifications provides concrete benefits. The shared language enables productive conversations about requirements, reduces misunderstandings during development, and creates a reference point for discussions about feature behavior.
Domain Complexity is High
Complex business domains benefit from specifications that capture domain logic explicitly. The specifications serve as a reference for business rules and help prevent misunderstandings about complex behavior.
Long-Lived Systems
Systems that will be maintained over many years benefit from living documentation. The investment in writing good specifications pays dividends as team members change and knowledge needs to be transferred.
Regulated Environments
Industries with regulatory requirements (finance, healthcare, aerospace) benefit from specifications that serve as auditable documentation of system behavior. Specifications can demonstrate compliance with regulations.
Consider Alternatives When
Conversely, Specification-Driven Development may not be the best fit when:
- Prototyping: Rapid prototyping benefits from less structure. Detailed specifications can slow exploration.
- Simple Domains: Simple applications with trivial business logic may not justify the overhead of acceptance-level specifications.
- Solo Projects: Individual developers may find the communication benefits less compelling and prefer simpler testing approaches.
- Legacy Code with No Tests: Retrofitting specifications to legacy code is challenging. Consider starting with characterization tests (Feathers, 2004) before transitioning to specification-driven approaches.
Integrating with TDD Practices
Specification-Driven Development complements existing TDD practices rather than replacing them. Teams can integrate SDD gradually:
Start with Acceptance Specifications
Begin by adding acceptance-level specifications for new features. This provides immediate value in terms of stakeholder communication and documentation without requiring changes to existing unit test practices.
Refactor Existing Tests
Gradually refactor existing tests to be more specification-like:
- Rename tests to describe behavior
- Remove assertions on internal state
- Focus on observable outcomes
- Use domain language in test names
Adopt Specification Tools Incrementally
Don’t feel obligated to adopt tools like Cucumber immediately. You can practice specification-driven thinking with existing test frameworks. Once the mindset is established, consider whether specialized tools provide additional value for your context.
Maintain the Balance
Not every test needs to be an acceptance specification. Maintain a balance:
- Use acceptance specifications for features and user journeys
- Use unit specifications for component behavior
- Use integration specifications for collaboration between components
The testing pyramid (Cohn, 2009) still applies: more unit-level specifications, fewer integration specifications, and even fewer acceptance specifications.
Related Practices
Specification-Driven Development intersects with several other software development practices:
Acceptance Test-Driven Development (ATDD)
ATDD (Hendrickson, 2008) emphasizes writing acceptance tests before development begins. SDD extends this by treating all tests as specifications, not just acceptance tests.
Specification by Example
Specification by Example (Adzic, 2011) focuses on using concrete examples to specify behavior. This technique works naturally with SDD, as examples become executable specifications.
Domain-Driven Design (DDD)
Domain-Driven Design (Evans, 2003) emphasizes modeling the domain and using ubiquitous language. SDD complements DDD by encoding domain concepts in executable specifications using that ubiquitous language.
Continuous Delivery
Specifications integrate naturally with continuous delivery pipelines. They provide fast feedback on whether changes break expected behavior and serve as deployment gates to prevent regressions from reaching production.
Practical Example: Building a Library System
To illustrate Specification-Driven Development in practice, consider building a library management system. This example demonstrates the workflow from acceptance specification to implementation.
Acceptance Specification
Start with a feature specification:
Feature: Book Borrowing
As a library member
I want to borrow books
So that I can read them at home
Scenario: Member borrows an available book
Given a member "Alice" with account in good standing
And a book "The Pragmatic Programmer" is available
When "Alice" borrows "The Pragmatic Programmer"
Then the book should be marked as borrowed
And "Alice" should have "The Pragmatic Programmer" in her borrowed books
And the due date should be 2 weeks from today
Scenario: Member cannot borrow more than the limit
Given a member "Bob" has borrowed 5 books (the maximum)
And a book "Clean Code" is available
When "Bob" attempts to borrow "Clean Code"
Then the borrowing should be rejected
And Bob should see an error message "You have reached your borrowing limit"
These specifications describe the feature in business terms. They are readable by product owners and serve as acceptance criteria.
Unit Specifications
To implement the feature, write specifications for the components:
describe('LibraryMember', () => {
describe('borrowBook', () => {
it('adds the book to the member's borrowed books', () => {
const member = new LibraryMember({ name: 'Alice', borrowedBooks: [] });
const book = new Book({ title: 'The Pragmatic Programmer' });
member.borrowBook(book);
expect(member.getBorrowedBooks()).toContain(book);
});
it('sets the due date to 2 weeks from today', () => {
const member = new LibraryMember({ name: 'Alice', borrowedBooks: [] });
const book = new Book({ title: 'The Pragmatic Programmer' });
const today = new Date('2024-01-01');
const expectedDueDate = new Date('2024-01-15');
member.borrowBook(book, today);
expect(member.getDueDateFor(book)).toEqual(expectedDueDate);
});
it('throws an error when member has reached borrowing limit', () => {
const member = new LibraryMember({
name: 'Bob',
borrowedBooks: [
new Book({ title: 'Book 1' }),
new Book({ title: 'Book 2' }),
new Book({ title: 'Book 3' }),
new Book({ title: 'Book 4' }),
new Book({ title: 'Book 5' })
]
});
const book = new Book({ title: 'Clean Code' });
expect(() => member.borrowBook(book)).toThrow(
'You have reached your borrowing limit'
);
});
});
});
These specifications describe component behavior at a lower level of abstraction. They focus on what the LibraryMember class does without exposing implementation details.
Implementation
With specifications in place, implement the code:
class LibraryMember {
constructor({ name, borrowedBooks = [] }) {
this.name = name;
this.borrowedBooks = borrowedBooks;
this.dueDates = new Map();
}
borrowBook(book, currentDate = new Date()) {
if (this.borrowedBooks.length >= 5) {
throw new Error('You have reached your borrowing limit');
}
this.borrowedBooks.push(book);
const dueDate = new Date(currentDate);
dueDate.setDate(dueDate.getDate() + 14);
this.dueDates.set(book, dueDate);
}
getBorrowedBooks() {
return this.borrowedBooks;
}
getDueDateFor(book) {
return this.dueDates.get(book);
}
}
The implementation satisfies the specifications. The specifications remain valid as documentation of the class’s behavior.
Benefits in This Example
This example demonstrates several SDD benefits:
- Clear Requirements: The acceptance specification clearly communicates what the borrowing feature should do.
- Test Coverage: Both happy paths and error cases are specified.
- Documentation: Someone unfamiliar with the code can read the specifications and understand the borrowing rules.
- Refactoring Safety: We could change how due dates are stored internally without breaking specifications as long as the public behavior remains the same.
Conclusion
Specification-Driven Development extends the test-first philosophy of TDD by emphasizing specifications as executable documentation. By focusing on behavior rather than implementation and using domain language where appropriate, SDD creates a documentation system that remains current and facilitates communication with stakeholders.
The practice is not universally applicable. Teams should consider their context, particularly the importance of stakeholder communication, domain complexity, and system longevity. For teams where these factors align, SDD provides concrete benefits through improved communication (shared vocabulary between technical and non-technical team members), living documentation (specifications that remain accurate because they are executable), and confident refactoring (behavior-focused tests that survive implementation changes).
Starting with SDD does not require wholesale changes. Teams can begin by adopting the specification mindset in existing tests, gradually introducing acceptance-level specifications, and eventually adopting specialized tools if they provide value. The key is maintaining focus on specifying behavior rather than testing implementation.
References
- Beck, K. (2002). Test Driven Development: By Example. Addison-Wesley Longman Publishing Co., Inc.
- North, D. (2006). Introducing BDD. Better Software Magazine. https://dannorth.net/introducing-bdd/
- Wynne, M., & Hellesoy, A. (2012). The Cucumber Book: Behaviour-Driven Development for Testers and Developers. Pragmatic Bookshelf.
- Freeman, S., & Pryce, N. (2009). Growing Object-Oriented Software, Guided by Tests. Addison-Wesley Professional.
- Chelimsky, D., Astels, D., Helmkamp, B., North, D., Dennis, Z., & Hellesoy, A. (2010). The RSpec Book: Behaviour Driven Development with RSpec, Cucumber, and Friends. Pragmatic Bookshelf.
- Dodds, K. C. (2018). Testing Library: Simple and complete testing utilities that encourage good testing practices. https://testing-library.com/
- Parnas, D. L. (1994). Software aging. Proceedings of the 16th International Conference on Software Engineering, 279–287.
- Adzic, G. (2011). Specification by Example: How Successful Teams Deliver the Right Software. Manning Publications.
- Matts, C. (2009). Example Mapping. https://cucumber.io/blog/bdd/example-mapping-introduction/
- Feathers, M. (2004). Working Effectively with Legacy Code. Prentice Hall Professional.
- Cohn, M. (2009). Succeeding with Agile: Software Development Using Scrum. Addison-Wesley Professional.
- Hendrickson, E. (2008). Acceptance Test Driven Development: An Overview. Agile Conference, 420–425.
- Evans, E. (2003). Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley Professional.
Additional Resources
- A Gentle Introduction to TDD
- Crafting a Solid Foundation with Outside-In TDD
- What do good assertions look like?
- Is TDD testing?