Photo by drmakete lab on Unsplash
Contents
- What is Clean Architecture?
- What Are the SOLID Principles?
- Why Use It?
- Directory Structure
- Key Components of Hexagonal Architecture
- Step-by-Step Guide with Red/Green Refactoring
- Step 1: Setting Up the Project
- Step 2: Red Phase - Write a Failing Test for the Post Entity
- Step 3: Green Phase - Implement the Post Entity
- Step 4: Refactor Phase - Improve Code Quality
- Step 5: Red Phase - Add a Failing Test for updateContent
- Step 6: Green Phase - Implement updateContent
- Step 7: Red Phase - Write a Failing Test for CreatePost Use Case
- Step 8: Green Phase - Implement CreatePost Use Case
- Step 9: Code Coverage
- Step 10: Refactor for better code quality
What is Clean Architecture?
Clean Architecture, also known as Hexagonal Architecture, is a software design paradigm that emphasizes modularity, maintainability, and testability. By decoupling the core business logic from external systems (like databases, APIs, or UI frameworks), applications become more adaptable and easier to evolve over time.
Code is often organised into layers, each layer with a specific responsibility:
- Core (or Domain) Layer: The heart of the application containing business logic, interfaces and entities.
- Application Layer: Coordinates workflows between the core and ports.
- Adapters Layer: Implementations of ports that allow interaction with specific technologies.
- Infrastructure Layer: Contains the application setup and configuration.
What are the SOLID Principles?
The SOLID principles guide developers in creating maintainable and scalable systems. These principles are foundational to Clean Architecture:
- Single Responsibility Principle (SRP): A class should only have one reason to change.
- Open/Closed Principle (OCP): Classes should be open for extension but closed for modification.
- Liskov Substitution Principle (LSP): Subtypes must be substitutable for their base types without altering program behavior.
- Interface Segregation Principle (ISP): Interfaces should only contain methods relevant to the implementing class.
- Dependency Inversion Principle (DIP): High-level modules should depend on abstractions, not on concrete implementations.
By adhering to these principles, we can build software that is easy to maintain, extend, and test.
Why Use It?
- Modularity: By separating concerns into different layers, the codebase becomes more modular and easier to understand.
- Testability: Isolating the core logic from external dependencies makes it easier to write unit tests.
- Flexibility: Adapters allow for easy swapping of technologies, making the application more adaptable to future changes.
- Maintainability: Separation of concerns makes the codebase easier to maintain over time.
Traditional Structure
In a typical express application - without clean architecture - the directory structure often looks as follows:
/api
├── src/
│ ├── controllers/
│ │ ├── postController.js
│ ├── models/
│ │ ├── postModel.js
│ ├── routes/
│ │ ├── postRoutes.js
│ ├── /middlewares
│ │ ├── authMiddleware.js
│ ├── utils/
│ │ ├── logger.js
│ ├── config/
│ │ ├── db.js
│ │ ├── env.js
│ ├── package.json
│ ├── app.js
├── tests/
├── .gitignore
├── package.json
├── tsconfig.json
└── README.md
Problems with the traditional structure
- Tight Coupling: Controllers often directly integrate with models creating tight coupling between the HTTP layer and database layer.
- Testability challenges: Testing the business logic often requires mocking external systems such as the database since the logic is intermingled with the controllers/models.
- Difficulty in swapping technologies: If we wanted to swap the database from an in-memory database to MongoDB, we would have to make significant changes to the controllers and models.
- Mixed responsiblities: The business logic can often end up spread across controllers, models and even utilities.
Clean Architecture Structure
In contrast a clean architecture organises the codebase into layers focusing on the separation of concerns, here is a simple example:
api/
│
├── src/
│ ├── core/ # Core business logic
│ │ ├── domain/ # Pure domain entities
│ │ │ ├── entities/ # Business entities (e.g., Post)
│ │ │ │ ├── Post.ts
│ │ │ │ └── ...
│ │ │ └── use-cases/ # Business use cases
│ │ │ ├── CreatePost.ts
│ │ │ ├── DeletePost.ts
│ │ │ └── ...
│ │ └── repositories/ # Repository interfaces (abstract)
│ │ ├── PostRepository.ts
│ │ └── ...
│ │
│ ├── infrastructure/ # Implementation of dependencies
│ │ ├── database/ # Database setup and entities
│ │ │ ├── connection.ts
│ │ │ └── postEntity.ts
│ │ └── repositories/ # Repository implementations
│ │ ├── PostRepositoryImpl.ts
│ │ └── ...
│ │
│ ├── application/ # Application layer logic
│ │ ├── controllers/ # API controllers
│ │ │ ├── PostController.ts
│ │ │ └── ...
│ │ ├── dtos/ # Data Transfer Objects
│ │ │ ├── CreatePostDTO.ts
│ │ │ └── ...
│ │ └── services/ # Application-level services
│ │ ├── ValidationService.ts
│ │ └── ...
│ │
│ └── config/ # Configuration files
│ ├── app.ts
│ ├── database.ts
│ └── ...
├── tests/ # Unit and integration tests
├── .gitignore
├── package.json
├── tsconfig.json
└── README.md
Key Components of Hexagonal Architecture
- Core
src/core
:
- Contains the business logic, entities, interfaces and rules of the application.
- Does not depend on any other layer.
- Example:
Post.ts
andPostRepository.ts
- Infrastructure
src/infrastructure
:
- Contains the application setup and configuration (e.g. Express app initialisation, environment variables, server setup).
- Example:
app.ts
andserver.ts
- Application Layer
src/application
:
- Coordinates the communication between the core (domain) business logic and the infrastructure layer.
- Contains controllers, dtos, and services.
- Example:
postController.ts
,createPostDTO.ts
, andvalidationService.ts
- Config
src/config
:
- Contains the application configuration (e.g. environment variables, database).
- Example:
app.ts
anddatabase.ts
Coding Task Analysis
We are tasked with building:
- a backend API that interacts with a third party API to fetch a graph representation of a user's social network
- a frontend React application that calls our backend API
What do we know?
- We are not expected to provide a real implementation of the third party API.
- The company favour clean architecture, good design practices and separation of concerns.
- They are looking for a solution where the candidate shows skills around application design, clean code and TDD.
- They do not expect us to have all fully working, just some test suites that we can run using your preferred build tool.
User Stories
- As a user, I want to query how many people are not connected to anyone for the given social network so I know who to propose new connections to.
Given a social network name Facebook
And a full Facebook graph
Return count of people with no connections
- As a user, I want to query how many people are connected to a given person by 1 or 2 degrees of separation for all social networks (facebook and twitter) so I understand her/his social influence.
Given a person name Peter
And a Facebook graph for Peter
And a Twitter graph for Peter
Return count of connections of 1 degree + count of connections of 2 degree
User Stories to Use Cases
- CountPeopleWithNoConnections
- CountConnectionsByDegrees
Entities
Given the example json
response from the third party API:
{
"name": "facebook",
"people": [
{ "name": "John" },
{ "name": "Harry" },
{ "name": "Peter" },
{ "name": "George" },
{ "name": "Anna" }
],
"relationships": [
{ "type": "HasConnection", "startNode": "John", "endNode": "Peter" },
{ "type": "HasConnection", "startNode": "John", "endNode": "George" },
{ "type": "HasConnection", "startNode": "Peter", "endNode": "George" },
{ "type": "HasConnection", "startNode": "Peter", "endNode": "Anna" }
]
}
We can keep things simple and define the following interfaces to get started:
Interfaces
SocialNetworkGraph
Directory Structure
social-network-presence/
│
├── backend/
│ ├── src/
│ │ ├── core/
│ │ │ ├── domain/ # Business logic
│ │ │ │ ├── interfaces/
│ │ │ │ │ └── SocialNetworkGraph.ts
│ │ │ │ └── use-cases/
│ │ │ │ ├── CountPeopleWithNoConnections.ts
│ │ │ │ └── CountConnectionsByDegrees.ts
│ │ │ └── infrastructure/ # External dependencies
│ │ │ │ └── services/ # Third-party API interactions
│ │ │ │ └── SocialNetworkGraphServiceImpl.ts
│ │ └──server.ts
│ ├── tests/ # Test cases
│ │ ├── core/
│ │ │ └── use-cases/
│ │ │ ├── CountPeopleWithNoConnections.spec.ts
│ │ │ └── CountConnectionsByDegrees.spec.ts
│ │ └── infrastructure/
│ │ └── services/
│ │ └── SocialNetworkGraphService.spec.ts
│ └── package.json
├── frontend/ # React frontend
│ ├── src/
│ └── package.json
├── package.json
└── README.md
Step-by-Step Guide with Red/Green Refactoring
Red/Green Refactoring
Red/Green Refactoring is a disciplined test-driven development (TDD) approach that involves three distinct phases:
- Red Phase: Write a failing test that defines the expected behavior of a feature or function.
- Green Phase: Write the simplest code possible to make the test pass.
- Refactor Phase: Improve the code while ensuring the test continues to pass.
This approach ensures that your code is both functional and maintainable, with tests driving the design and implementation. In this article, we’ll use red/green refactoring to incrementally build a Blog backend application, starting with minimal functionality and expanding step by step.
Step 1: Setting Up the Project
Before diving into code, let's set up the bare essentials:
- Create a new directory for the project, navigate into it and initialise a node
package.json
file:
mkdir social-network-presence
cd social-network-presence
npm init -y
- Setup a repository on GitHub and push the initial codebase to the repository:
echo "# social-network-presence" >> README.md
git init
git add README.md
git commit -m "first commit"
git branch -M main
git remote add origin git@github.com:jonsully1/social-network-presence.git
git push -u origin main
- Create a basic directory structure for the project and move to the backend directory:
mkdir backend frontend
cd backend
- Install TypeScript and testing tools:
npm install typescript ts-node jest @types/jest ts-jest --save-dev
- Open the codebase in your preferred IDE and update the
package.json
scripts in thebackend
directory to run jest:
...
"scripts": {
"test": "jest"
}
...
- Configure TypeScript by creating a
tsconfig.json
file:
{
"compilerOptions": {
"target": "ES6",
"module": "commonjs",
"strict": true,
"outDir": "./dist"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
- Configure Jest by creating a
jest.config.json
file:
{
"preset": "ts-jest",
"testEnvironment": "node",
"transform": {
"^.+\\.tsx?$": "ts-jest"
},
"transformIgnorePatterns": ["node_modules/(?!YOUR_PACKAGE_NAME)"]
}
- Create a basic project structure - just the essentials for now, we will expand this in the following steps:
social-network-presence/
├── backend/
│ ├── node_modules/
│ ├── src/
│ │ ├── core/
│ │ │ ├── domain/
│ │ │ │ ├── entities/
│ │ │ │ └── use-cases/
│ ├── tests/
├── jest.config.json
├── package.json
├── tsconfig.json
└── README.md
- Create a
.gitignore
file to excludenode_modules
directory from being committed to the repository:
Code: .gitignore
node_modules/
- Create an example test to confirm our setup is working:
Test: src/tests/exampleTest.spec.ts
describe('Example Test', () => {
it('should return true when the value is true', () => {
const value = true;
expect(value).toBe(true);
});
});
- Configure code coverage and thresholds to ensure we are meeting some minimum coverage goals.
Code: ./backend/jest.config.json
{
"preset": "ts-jest",
"testEnvironment": "node",
"transform": {
"^.+\\.tsx?$": "ts-jest"
},
"transformIgnorePatterns": ["node_modules/(?!YOUR_PACKAGE_NAME)"],
"collectCoverage": true, // added
"coverageDirectory": "coverage", // added
"collectCoverageFrom": ["src/**/*.ts"], // added
"coverageThreshold": {
// added
"global": {
"branches": 80,
"functions": 90,
"lines": 95,
"statements": 95
}
}
}
- Add
--watch
flag to thetest
script in thepackage.json
file to enable watch mode.
Code: ./backend/package.json
...
"scripts": {
"test": "jest --watch"
}
...
We're all set up:
PASS tests/exampleTest.spec.ts
Example Test
✓ should return true when the value is true (3 ms)
----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files | 0 | 0 | 0 | 0 |
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 0.355 s, estimated 1 s
Ran all test suites related to changed files.
Watch Usage
› Press a to run all tests.
› Press f to run only failed tests.
› Press p to filter by a filename regex pattern.
› Press t to filter by a test name regex pattern.
› Press q to quit watch mode.
› Press Enter to trigger a test run.
Step 2: Red Phase - Write a Failing Test for the CountPeopleWithNoConnections
Use Case
The first thing we’ll do is create a failing test for the CountPeopleWithNoConnections
use case.
Test: src/tests/core/use-cases/CountPeopleWithNoConnections.spec.ts
import { SocialNetworkGraph } from '../../../../src/core/domain/interfaces/SocialNetworkGraph';
import { CountPeopleWithNoConnections } from '../../../../src/core/domain/use-cases/CountPeopleWithNoConnections';
import { SocialNetworkGraphService } from '../../../../src/core/domain/services/SocialNetworkGraphService';
// Mock data indicates one person with no connections
const mockGraph: SocialNetworkGraph = {
name: 'facebook',
people: [
{ name: 'John' },
{ name: 'Harry' },
{ name: 'Peter' },
{ name: 'George' },
{ name: 'Anna' },
],
relationships: [
{ type: 'HasConnection', startNode: 'John', endNode: 'Peter' },
{ type: 'HasConnection', startNode: 'John', endNode: 'George' },
{ type: 'HasConnection', startNode: 'Peter', endNode: 'George' },
{ type: 'HasConnection', startNode: 'Peter', endNode: 'Anna' },
],
};
class MockSocialNetworkGraphService implements SocialNetworkGraphService {
// implement method and props
}
describe('CountPeopleWithNoConnections', () => {
const mockSocialNetworkGraphService = new MockSocialNetworkGraphService();
const countPeopleWithNoConnections = new CountPeopleWithNoConnections(
mockSocialNetworkGraphService,
);
it('should return 0 when there are no people with no connections', () => {
const count = countPeopleWithNoConnections.execute(mockGraph);
expect(count).toBe(1);
});
});
Notice that the path to our test file is nested to reflect the structural location of the file under test in our clean architecture. This is good practice, it makes the codebase easier to understand and navigate.
Run the test:
npm run test
And as expected, the test fails as we have not yet created the SocialNetworkGraph
interface, SocialNetworkGraphService
interface, or the CountPeopleWithNoConnections
use case:
FAIL tests/core/domain/use-cases/CountPeopleWithNoConnections.spec.ts
● Test suite failed to run
tests/core/domain/use-cases/CountPeopleWithNoConnections.spec.ts:1:36 - error TS2307: Cannot find module '../../../../src/core/domain/interfaces/SocialNetworkGraph' or its corresponding type declarations.
1 import { SocialNetworkGraph } from "../../../../src/core/domain/interfaces/SocialNetworkGraph";
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
tests/core/domain/use-cases/CountPeopleWithNoConnections.spec.ts:2:46 - error TS2307: Cannot find module '../../../../src/core/domain/use-cases/CountPeopleWithNoConnections' or its corresponding type declarations.
2 import { CountPeopleWithNoConnections } from "../../../../src/core/domain/use-cases/CountPeopleWithNoConnections";
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
tests/core/domain/use-cases/CountPeopleWithNoConnections.spec.ts:3:43 - error TS2307: Cannot find module '../../../../src/core/domain/services/SocialNetworkGraphService' or its corresponding type declarations.
3 import { SocialNetworkGraphService } from "../../../../src/core/domain/services/SocialNetworkGraphService";
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files | 0 | 0 | 0 | 0 |
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 failed, 1 total
Tests: 0 total
Snapshots: 0 total
Time: 1.288 s
Ran all test suites.
Step 3: Green Phase - Make the CountPeopleWithNoConnections
Use Case Pass
Let's create the missing interfaces and mock implementation details to make the test pass.
SocialNetworkGraph Interface
Code: src/core/domain/interfaces/SocialNetworkGraph.ts
interface Person {
name: string;
}
interface Relationship {
type: string;
startNode: string;
endNode: string;
}
export interface SocialNetworkGraph {
name: string;
people: Person[];
relationships: Relationship[];
}
SocialNetworkGraphService Interface
Code: src/core/domain/services/SocialNetworkGraphService.ts
import { SocialNetworkGraph } from '../interfaces/SocialNetworkGraph';
export interface SocialNetworkGraphService {
countPeopleWithNoConnections(graph: SocialNetworkGraph): number;
}
CountPeopleWithNoConnections Use Case
import { SocialNetworkGraph } from '../interfaces/SocialNetworkGraph';
import { SocialNetworkGraphService } from '../services/SocialNetworkGraphService';
export class CountPeopleWithNoConnections {
#socialNetworkGraphService: SocialNetworkGraphService;
constructor(socialNetworkGraphService: SocialNetworkGraphService) {
this.#socialNetworkGraphService = socialNetworkGraphService;
}
execute(graph: SocialNetworkGraph): number {
return this.#socialNetworkGraphService.countPeopleWithNoConnections(graph);
}
}
Update the CountPeopleWithNoConnections
test
import { SocialNetworkGraph } from '../../../../src/core/domain/interfaces/SocialNetworkGraph';
import { SocialNetworkGraphService } from '../../../../src/core/domain/services/SocialNetworkGraphService';
import { CountPeopleWithNoConnections } from '../../../../src/core/domain/use-cases/CountPeopleWithNoConnections';
const mockGraph: SocialNetworkGraph = {
name: 'facebook',
people: [
{ name: 'John' },
{ name: 'Harry' },
{ name: 'Peter' },
{ name: 'George' },
{ name: 'Anna' },
],
relationships: [
{ type: 'HasConnection', startNode: 'John', endNode: 'Peter' },
{ type: 'HasConnection', startNode: 'John', endNode: 'George' },
{ type: 'HasConnection', startNode: 'Peter', endNode: 'George' },
{ type: 'HasConnection', startNode: 'Peter', endNode: 'Anna' },
],
};
class MockSocialNetworkGraphService implements SocialNetworkGraphService {
countPeopleWithNoConnections(graph: SocialNetworkGraph): number {
const connectedPeople = new Set<string>();
for (const relationship of graph.relationships) {
const { type, startNode, endNode } = relationship;
if (type === 'HasConnection') {
connectedPeople.add(startNode);
connectedPeople.add(endNode);
}
}
return graph.people.filter((person) => !connectedPeople.has(person.name)).length;
}
}
describe('CountPeopleWithNoConnections', () => {
const mockSocialNetworkGraphService = new MockSocialNetworkGraphService();
const countPeopleWithNoConnections = new CountPeopleWithNoConnections(
mockSocialNetworkGraphService,
);
it('should return 0 when there are no people with no connections', () => {
const count = countPeopleWithNoConnections.execute(mockGraph);
expect(count).toBe(1);
});
});
Run the test again:
npm run test
And we have a passing test:
PASS tests/core/domain/use-cases/CountPeopleWithNoConnections.spec.ts
CountPeopleWithNoConnections
✓ should return 0 when there are no people with no connections (3 ms)
--------------------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
--------------------------------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 100 | 100 |
...tPeopleWithNoConnections.ts | 100 | 100 | 100 | 100 |
--------------------------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 2.364 s
Ran all test suites related to changed files.
Step 4: Refactor Phase - Improve Code Quality
First lets refactor our test file.
We can:
- use
beforeEach
to create themockSocialNetworkGraphService
andcountPeopleWithNoConnections
instances once before each test as we might want to reuse them in multiple tests. - fix the typo in the test name, we accidentally named our test, "should return 0 when there are no people with no connections", when we were actually asserting that the count of people with no connections is 1. Therefore we should rename the test to "should return 1 when there is one person with no connections".
- write a second test to check that the count of people with no connections is 0 when there are no people with no connections.
- move our mock data into a
mocks
directory to clean up the test file and allow for reuse in other tests. - move our
MockSocialNetworkGraphService
class into amocks
directory as we'll reuse it in other tests.
CountPeopleWithNoConnections Use Case Tests
Our test file is now clean, readable, and easy to understand even with multiple tests.
Path: src/tests/core/domain/use-cases/CountPeopleWithNoConnections.spec.ts
import { CountPeopleWithNoConnections } from '../../../../src/core/domain/use-cases/CountPeopleWithNoConnections';
import { MockSocialNetworkGraphService } from '../mocks/MockSocialNetworkGraphService';
import {
mockGraphAllPeopleConnected,
mockGraphOnePersonWithNoConnections,
} from '../mocks/graphJsonResponse';
describe('CountPeopleWithNoConnections', () => {
let mockSocialNetworkGraphService: MockSocialNetworkGraphService;
let countPeopleWithNoConnections: CountPeopleWithNoConnections;
beforeEach(() => {
mockSocialNetworkGraphService = new MockSocialNetworkGraphService();
countPeopleWithNoConnections = new CountPeopleWithNoConnections(mockSocialNetworkGraphService);
});
it('should return 1 when there is one person with no connections', () => {
const count = countPeopleWithNoConnections.execute(mockGraphOnePersonWithNoConnections);
expect(count).toBe(1);
});
it('should return 0 when there are no people with no connections', () => {
const count = countPeopleWithNoConnections.execute(mockGraphAllPeopleConnected);
expect(count).toBe(0);
});
});
Mock Data & Service
Our mock graph response data and MockSocialNetworkGraphService
implementation are now in a dedicated mocks
directory for reuse in other tests.
Path: src/tests/core/domain/mocks/graphResponseData.ts
import { SocialNetworkGraph } from '../../../../src/core/domain/interfaces/SocialNetworkGraph';
export const mockGraphOnePersonWithNoConnections: SocialNetworkGraph = {
name: 'facebook',
people: [
{ name: 'John' },
{ name: 'Harry' }, // Has no connections
{ name: 'Peter' },
{ name: 'George' },
{ name: 'Anna' },
],
relationships: [
{ type: 'HasConnection', startNode: 'John', endNode: 'Peter' },
{ type: 'HasConnection', startNode: 'John', endNode: 'George' },
{ type: 'HasConnection', startNode: 'Peter', endNode: 'George' },
{ type: 'HasConnection', startNode: 'Peter', endNode: 'Anna' },
],
};
export const mockGraphAllPeopleConnected: SocialNetworkGraph = {
name: 'facebook',
people: [
{ name: 'John' },
{ name: 'Harry' },
{ name: 'Peter' },
{ name: 'George' },
{ name: 'Anna' },
],
relationships: [
{ type: 'HasConnection', startNode: 'John', endNode: 'Peter' },
{ type: 'HasConnection', startNode: 'John', endNode: 'George' },
{ type: 'HasConnection', startNode: 'Peter', endNode: 'George' },
{ type: 'HasConnection', startNode: 'Peter', endNode: 'Anna' },
{ type: 'HasConnection', startNode: 'Harry', endNode: 'Anna' },
],
};
Code: src/tests/core/domain/mocks/MockSocialNetworkGraphService.ts
import { SocialNetworkGraph } from '../../../../src/core/domain/interfaces/SocialNetworkGraph';
import { SocialNetworkGraphService } from '../../../../src/core/domain/services/SocialNetworkGraphService';
export class MockSocialNetworkGraphService implements SocialNetworkGraphService {
countPeopleWithNoConnections(graph: SocialNetworkGraph): number {
const connectedPeople = new Set<string>();
for (const relationship of graph.relationships) {
const { type, startNode, endNode } = relationship;
if (type === 'HasConnection') {
connectedPeople.add(startNode);
connectedPeople.add(endNode);
}
}
return graph.people.filter((person) => !connectedPeople.has(person.name)).length;
}
}
Directory Structure
We have just noticed we have an uneccessary nested directory structure in core/domain
. core
and domain
represent the same layer in clean architecture, so we can simply use one, let's go with domain
and remove core
from our directory structure in both the src
and tests
directories.
❯ tree --gitignore --dirsfirst
.
├── backend
│ ├── src
│ │ └── domain
│ │ ├── entities
│ │ ├── interfaces
│ │ │ └── SocialNetworkGraph.ts
│ │ ├── services
│ │ │ └── SocialNetworkGraphService.ts
│ │ └── use-cases
│ │ └── CountPeopleWithNoConnections.ts
│ ├── tests
│ │ └── domain
│ │ ├── mocks
│ │ │ ├── MockSocialNetworkGraphService.ts
│ │ │ └── graphResponseData.ts
│ │ └── use-cases
│ │ └── CountPeopleWithNoConnections.spec.ts
│ ├── jest.config.json
│ ├── package-lock.json
│ ├── package.json
│ └── tsconfig.json
├── frontend
├── README.md
└── package.json
See https://formulae.brew.sh/formula/tree for more information on the tree
command.
Step 5: Red Phase - Write a failing test for the CountDegreesOfSeperation
Use Case
Let's get our 2nd test case in place by writing a failing test for the CountConnectionsByDegrees
use case, let's just make the use case name more explicit: CountDegreesOfSeperation
. This is much more descriptive and makes it clear what the test aims to achieve.
We know from the code challenge that Given a person name Peter
, we need to Return the count of connections of 1 degree + the count of connections of 2 degrees of separation
.
1 degree | 2 degrees | |
---|---|---|
John | 2 | 1 |
Peter | 3 | 0 |
George | 2 | 1 |
Harry | 0 | 0 |
Anna | 1 | 2 |
We also need to take into account both a facebook
and twitter
social network graph, but let's start with a single graph for simplicity, the facebook
graph.
As our previous CountPeopleWithNoConnections
test was refactored, we can follow the same pattern to create our new test.
Code: src/tests/core/domain/use-cases/CountDegreesOfSeperation.spec.ts
import { CountDegreesOfSeperation } from '../../../src/domain/use-cases/CountDegreesOfSeperation';
import { MockSocialNetworkGraphService } from '../mocks/MockSocialNetworkGraphService';
import { mockGraphAllPeopleConnected } from '../mocks/graphResponseData';
describe('CountDegreesOfSeperation', () => {
let mockSocialNetworkGraphService: MockSocialNetworkGraphService;
let countDegreesOfSeperation: CountDegreesOfSeperation;
beforeEach(() => {
mockSocialNetworkGraphService = new MockSocialNetworkGraphService();
countDegreesOfSeperation = new CountDegreesOfSeperation(mockSocialNetworkGraphService);
});
it('should return 1 when there is one person with no connections', () => {
const count = countDegreesOfSeperation.execute(mockGraphAllPeopleConnected);
const degreesOfSeparationCount = { 1: 1, 2: 2 };
expect(count).toBe(expected);
});
});
Naturally, the test will fail as we have not yet implemented the CountDegreesOfSeperation
use case.
We also get a typescript error in the file - Property 'countDegreesOfSeperation' does not exist on type 'SocialNetworkGraphService'.ts(2339)
- and of course our test will fail as the countDegreesOfSeperation
method is not yet implemented on the MockSocialNetworkGraphService
class.
FAIL tests/domain/use-cases/CountConnectionsByDegressOfSeperation.spec.ts
● Test suite failed to run
src/domain/use-cases/CountConnectionsByDegressOfSeperation.ts:12:44 - error TS2339: Property 'countDegreesOfSeperation' does not exist on type 'SocialNetworkGraphService'.
12 return this.#socialNetworkGraphService.countDegreesOfSeperation(graph);
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
PASS tests/domain/use-cases/CountPeopleWithNoConnections.spec.ts
Running coverage on untested files...Failed to collect coverage from /Users/johnosullivan/dev/johno/success-response/social-network-presence/backend/src/domain/use-cases/CountConnectionsByDegressOfSeperation.ts
ERROR: src/domain/use-cases/CountConnectionsByDegressOfSeperation.ts:12:44 - error TS2339: Property 'countDegreesOfSeperation' does not exist on type 'SocialNetworkGraphService'.
12 return this.#socialNetworkGraphService.countDegreesOfSeperation(graph);
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
STACK:
----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files | 0 | 0 | 0 | 0 |
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 failed, 1 passed, 2 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 1.618 s
Ran all test suites.
Step 6: Green Phase - Implement the CountDegreesOfSeperation
Use Case
Let's add the CountDegreesOfSeperation
use case and the countDegreesOfSeperation
method to the MockSocialNetworkGraphService
class:
Code: src/domain/use-cases/CountDegreesOfSeperation.ts
import { SocialNetworkGraph } from '../interfaces/SocialNetworkGraph';
import { DegreesOfSeparationCount } from '../interfaces/DegreesOfSeparationCount';
import { SocialNetworkGraphService } from '../services/SocialNetworkGraphService';
export class CountDegreesOfSeparation {
#socialNetworkGraphService: SocialNetworkGraphService;
constructor(socialNetworkGraphService: SocialNetworkGraphService) {
this.#socialNetworkGraphService = socialNetworkGraphService;
}
execute(person: string, socialNetworkGraph: SocialNetworkGraph): DegreesOfSeparationCount {
return this.#socialNetworkGraphService.countDegreesOfSeparation(person, socialNetworkGraph);
}
}
Code: src/tests/domain/mocks/MockSocialNetworkGraphService.ts
We hardcode the expected values in the test, but we need to implement the logic to calculate the degrees of separation in the countDegreesOfSeparation
method.
import { SocialNetworkGraph } from '../../../src/domain/interfaces/SocialNetworkGraph';
import { SocialNetworkGraphService } from '../../../src/domain/services/SocialNetworkGraphService';
export class MockSocialNetworkGraphService implements SocialNetworkGraphService {
countPeopleWithNoConnections(graph: SocialNetworkGraph): number {
const connectedPeople = new Set<string>();
for (const relationship of graph.relationships) {
const { type, startNode, endNode } = relationship;
if (type === 'HasConnection') {
connectedPeople.add(startNode);
connectedPeople.add(endNode);
}
}
return graph.people.filter((person) => !connectedPeople.has(person.name)).length;
}
countDegreesOfSeparation(graph: SocialNetworkGraph): DegreesOfSeparationCount {
return { 1: 1, 2: 2 };
}
}
Code: src/domain/services/SocialNetworkGraphService.ts
import { SocialNetworkGraph } from '../interfaces/SocialNetworkGraph';
import { DegreesOfSeparationCount } from '../interfaces/DegreesOfSeparationCount';
export interface SocialNetworkGraphService {
countPeopleWithNoConnections(graph: SocialNetworkGraph): number;
countDegreesOfSeparation(graph: SocialNetworkGraph): DegreesOfSeparationCount;
}
Code: src/domain/interfaces/DegreesOfSeparationCount.ts
The Degree
type is a union of the possible values for the degrees of separation, i.e. 0 (starting point), 1, and 2.
type Degree = 0 | 1 | 2;
export interface DegreesOfSeparationCount {
1: Degree;
2: Degree;
}
We expect the test to pass now, it doesn't but only because we are using toBe
instead of toStrictEqual
, as we're comparing objects, not primitive values.
PASS tests/domain/use-cases/CountPeopleWithNoConnections.spec.ts
FAIL tests/domain/use-cases/CountConnectionsByDegressOfSeperation.spec.ts
● CountDegreesOfSeperation › should return 1 when there is one person with no connections
expect(received).toBe(expected) // Object.is equality
If it should pass with deep equality, replace "toBe" with "toStrictEqual"
Expected: { 1: 1, 2: 2 }
Received: serializes to the same string
19 |
20 | const expected = { 1: 1, 2: 2 };
> 21 | expect(count).toBe(expected);
| ^
22 | });
23 | });
24 |
at Object.<anonymous> (tests/domain/use-cases/CountConnectionsByDegressOfSeperation.spec.ts:21:19)
We fix it and the tests pass but we're currently returning a hardcoded value - { 1: 1, 2: 2 }
in the MockSocialNetworkGraphService
class to fullfil the test. We need to implement the logic to calculate the number of connections by degrees of separation.
So let's just initialise and return a count
object that we'll increment during the implementation of the method in the Green Phase.
Code: src/tests/domain/mocks/MockSocialNetworkGraphService.ts
...
export class MockSocialNetworkGraphService
implements SocialNetworkGraphService
{
...
countDegreesOfSeparation(graph: SocialNetworkGraph): DegreesOfSeparationCount {
const count = { 1: 0, 2: 0 };
return count;
}
}
Impementing the countDegreesOfSeparation
Method
The degree of separation measures how far one person is from another in terms of connections:
- 1 degree of separation: Direct connections (e.g., John is directly connected to Peter).
- 2 degrees of separation: Indirect connections through one intermediary (e.g., John → Peter → Anna).
Degrees of separation can help measure someone's social influence by analysing how many people they can reach directly or indirectly.
The response that we receive from the third party API is a representation of the social network graph:
{
"sn": "facebook",
"people": [
{ "name": "John" },
{ "name": "Harry" },
{ "name": "Peter" },
{ "name": "George" },
{ "name": "Anna" }
],
"relationships": [
{ "type": "HasConnection", "startNode": "John", "endNode": "Peter" },
{ "type": "HasConnection", "startNode": "John", "endNode": "George" },
{ "type": "HasConnection", "startNode": "Peter", "endNode": "George" },
{ "type": "HasConnection", "startNode": "Peter", "endNode": "Anna" }
]
}
Graphs are represented as a collection of nodes (vertices) and edges (connections between nodes).
In the reponse above, the people
array represents the nodes, and the relationships
array represents the edges.
Graphs can be represented in a number of ways, but for our purposes, we will represent the graph as an adjacency list, i.e. an object where each key is a node and the value is an array of connected nodes.
const graph = {
John: ['Peter', 'George'],
Peter: ['George', 'Anna'],
// ...
};
We will create the above graph object in order to efficiently carry out our calculation to count the degrees of separation.
Let's begin adding the logic to the countDegreesOfSeparation
method in the MockSocialNetworkGraphService
class.
Something that we have missed though is the person
argument to the countDegreesOfSeparation
method. Referring back to the first line of user story, Given a person name Peter
, we need to return the count of connections of 1 degree + the count of connections of 2 degrees of separation for a specified person.
Let's add that in as we build out the logic in the countDegreesOfSeparation
method of the MockSocialNetworkGraphService
class.
Code: src/tests/domain/mocks/MockSocialNetworkGraphService.ts
First we will build the adjacency list from the graph representation response.
export class MockSocialNetworkGraphService
implements SocialNetworkGraphService
{
...
countDegreesOfSeparation(
person: string,
socialNetworkGraph: SocialNetworkGraph,
): DegreesOfSeparationCount {
const count = { 1: 0, 2: 0 };
const { people, relationships } = socialNetworkGraph;
const graph: Graph = {};
// Initialize the graph with empty arrays for each person
for (const person of people) {
graph[person.name] = [];
}
// Populate the graph with connections
for (const relationship of relationships) {
const { type, startNode, endNode } = relationship;
if (type === "HasConnection") {
graph[startNode].push(endNode);
graph[endNode].push(startNode);
}
}
console.dir({ graph }, { depth: null });
return count;
}
}
We can see from the console output of our failing test that the graph has been created successfully:
PASS tests/domain/use-cases/CountPeopleWithNoConnections.spec.ts
FAIL tests/domain/use-cases/CountDegressOfSeperation.spec.ts
● Console
console.dir
{
graph: {
John: [ 'Peter', 'George' ],
Harry: [],
Peter: [ 'John', 'George', 'Anna' ],
George: [ 'John', 'Peter' ],
Anna: [ 'Peter' ]
}
}
at MockSocialNetworkGraphService.countDegreesOfSeparation (tests/domain/mocks/MockSocialNetworkGraphService.ts:47:13)
● CountConnectionsByDegreesOfSeparation › should return 2 connections at 1 degree and 1 connection at 2 degrees for John
expect(received).toStrictEqual(expected) // deep equality
...
Next we need to implement the logic to calculate the degrees of separation for the specified person.
We essentially need to create a FIFO queue to perform a breadth-first search (BFS) of the graph to count the degrees of separation for the specified person.
BFS is the most appropriate algorithm for this task as it explores all nodes at the present depth prior to moving on to the nodes at the next depth level.
Other algorithms such as depth-first search (DFS) are not suitable as they explore nodes in a depth-first manner, potentially skipping nodes at lower depths.
Code: src/tests/domain/mocks/MockSocialNetworkGraphService.ts
export class MockSocialNetworkGraphService
implements SocialNetworkGraphService
{
...
countDegreesOfSeparation(
person: string,
socialNetworkGraph: SocialNetworkGraph,
): DegreesOfSeparationCount {
const count: DegreesOfSeparationCount = { 1: 0, 2: 0 };
const { people, relationships } = socialNetworkGraph;
const graph: Graph = {};
// Initialize the graph with empty arrays for each person
for (const person of people) {
graph[person.name] = [];
}
// Populate the graph with connections
for (const relationship of relationships) {
const { type, startNode, endNode } = relationship;
if (type === "HasConnection") {
graph[startNode].push(endNode);
graph[endNode].push(startNode);
}
}
// FIFO queue (Breadth-First Search)
const visited = new Set();
const queue: (string | number)[][] = [[person, 0]];
while (queue.length) {
const [current, degree] = queue.shift() as [string, number]; // 1
if (visited.has(current)) {
continue; // 2
}
visited.add(current); // 3
if (degree > 0 && degree <= 2) {
count[degree as 1 | 2]++; // 4
}
if (degree < 2) {
(graph[current] || []).forEach((neighbor) => { // 5
if (!visited.has(neighbor)) {
queue.push([neighbor, degree + 1]);
}
});
}
}
return count;
}
}
How the FIFO Queue Works
We already had the count
object initialised to store the count of connections by degrees of separation.
Now we have created:
- A
visited
JavScriptSet
to store the nodes (people) that have already been visited.- Used to prevent us from visiting the same node multiple times.
- A
queue
array to store the nodes (people) that are yet to be visited - it's like a todo list.- initialised with the starting node (
person
argument) and a degree of 0 - degree is 0 because the
person
is the root node, all other connections are 1 degree of separation away, i.e. Peter's immediate connections would be considered 1 degree of separation away.
- initialised with the starting node (
- A
while
loop to iterate over thequeue
until it is empty.
Explanation of the while
Loop:
As we interate over the queue
we carry out the following actions:
- Dequeue the first element from the
queue
. - If the node has already been visited, we skip this iteration of the while loop.
- If the node has not been visited, we add it to the
visited
Set object as we are now visiting it. - If the
degree
value (degree of separation) is greater than 0 and less than or equal to 2, we increment the count for that degree of separation. - If the
degree
value is less than 2, we get the neighbours of the current node
forEach
neighbour, if it has not been visited, we enqueue it with an incremented degree of separation.
Example Walkthrough:
Let’s assume the graph looks like this:
{
"John": ["Peter", "George"],
"Peter": ["John", "Anna"],
"George": ["John"],
"Anna": ["Peter"]
}
Starting with John
as the person
argument:
Initial State
- Queue:
[["John", 0]]
- Visited:
{}
Nothing to enqueue as we're starting with the root node.
Iteration 1: Process "John"
- Queue:
[]
- "John" is dequeued
- Current:
0
- because "John" is the root node, and we're starting with the root node
- Neighbours:
["Peter", "George"]
- Visited:
{"John"}
- Count:
{1: 0, 2: 0}
Enqueue John's neighbours:
- Queue:
[["Peter", 1], ["George", 1]]
- Neighbours are enqueued with a degree of 1 as they are 1 degree of separation away from John
Iteration 2: Process "Peter"
- Queue:
[["George", 1]]
- "Peter" is dequeued for processing, leaving "George" as next node to be processed by the while loop
- Current:
1
- because "Peter" is 1 degree of separation away from John, as set in the previous step
- Neighbours:
["John", "Anna"]
- Visited:
{"John", "Peter"}
- Count:
{1: 1, 2: 0}
- increment 1-degree count for "Peter"
Enqueue Peter's neighbours:
- Queue:
[["George", 1], ["Anna", 2]]
- "John" was already visited, so he is not enqueued
Iteration 3: Process "George"
- Queue:
[["Anna", 2]]
- "George" is dequeued for processing
- Current:
1
- because "George" is 1 degree of separation away from John, as set in the previous steps
- Neighbours:
["John"]
- Visited:
{"John", "Peter", "George"}
- Count:
{1: 2, 2: 0}
- increment 1-degree count for "George"
No new neighbours to enqueue as John has already been visited.
Iteration 4: Process "Anna"
- Queue:
[]
- "Anna" is dequeued for processing
- Current:
2
- because "Anna" is 2 degrees of separation away from John, as set in the previous steps
- Neighbours:
["Peter"]
- Visited:
{"John", "Peter", "George", "Anna"}
- Count:
{1: 2, 2: 1}
- increment 2-degree count for "Anna"
No new neighbours to enqueue as Peter has already been visited.
Final Results
- 1-Degree Connections: "Peter", "George"
- 2-Degree Connections: "Anna"
- Counts:
{1: 2, 2: 1}
And our test passes:
PASS tests/domain/use-cases/CountPeopleWithNoConnections.spec.ts
PASS tests/domain/use-cases/CountDegressOfSeperation.spec.ts
-----------------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------------------------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 100 | 100 |
CountDegressOfSeperation.ts | 100 | 100 | 100 | 100 |
-----------------------------|---------|----------|---------|---------|-------------------
Test Suites: 2 passed, 2 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 1.854 s
Ran all test suites related to changed files.
Step 7: Refactor Phase - Improve Code Quality
First up let's add a few more tests for each of the people
in the current mock response data.
Code: tests/domain/use-cases/CountDegressOfSeperation.spec.ts
We uncomment a test from the previous step and fix it but immediately find we have made an error in our DegressOfSeparationCount
type:
PASS tests/domain/use-cases/CountPeopleWithNoConnections.spec.ts
FAIL tests/domain/use-cases/CountDegressOfSeperation.spec.ts
● Test suite failed to run
tests/domain/use-cases/CountDegressOfSeperation.spec.ts:35:61 - error TS2322: Type '3' is not assignable to type 'Degree'.
35 const degreesOfSeparation: DegreesOfSeparationCount = { 1: 3, 2: 0 };
~
src/domain/interfaces/DegreesOfSeparationCount.ts:3:3
3 1: Degree;
~
The expected type comes from property '1' which is declared here on type 'DegreesOfSeparationCount'
The actual counts should be number
not a union type so let's fix that.
Code: src/domain/interfaces/DegreesOfSeparationCount.ts
export interface DegreesOfSeparationCount {
1: number;
2: number;
}
It worked, all tests pass, we just need to add the remaining tests:
Code: tests/domain/use-cases/CountDegressOfSeperation.spec.ts
describe("CountConnectionsByDegreesOfSeparation", () => {
...
it("should return 2 connections at 1 degree and 1 connection at 2 degrees for John", () => {
const person = "John";
const count = countDegreesOfSeparation.execute(
person,
mockGraphOnePersonWithNoConnections,
);
const degreesOfSeparation: DegreesOfSeparationCount = { 1: 2, 2: 1 };
expect(count).toStrictEqual(degreesOfSeparation);
});
it("should return 3 connections at 1 degree and 0 connections at 2 degrees for Peter", () => {
const person = "Peter";
const count = countDegreesOfSeparation.execute(
person,
mockGraphOnePersonWithNoConnections,
);
const degreesOfSeparation: DegreesOfSeparationCount = { 1: 3, 2: 0 };
expect(count).toStrictEqual(degreesOfSeparation);
});
it("should return 2 connections at 1 degree and 1 connections at 2 degrees for George", () => {
const person = "George";
const count = countDegreesOfSeparation.execute(
person,
mockGraphOnePersonWithNoConnections,
);
const degreesOfSeparation: DegreesOfSeparationCount = { 1: 2, 2: 1 };
expect(count).toStrictEqual(degreesOfSeparation);
});
it("should return 0 connections at both 1 degree and 2 degrees for Harry", () => {
const person = "Harry";
const count = countDegreesOfSeparation.execute(
person,
mockGraphOnePersonWithNoConnections,
);
const degreesOfSeparation: DegreesOfSeparationCount = { 1: 0, 2: 0 };
expect(count).toStrictEqual(degreesOfSeparation);
});
it("should return 1 connections at 1 degree and 2 connections at 2 degrees for Anna", () => {
const person = "Anna";
const count = countDegreesOfSeparation.execute(
person,
mockGraphOnePersonWithNoConnections,
);
const degreesOfSeparation: DegreesOfSeparationCount = { 1: 1, 2: 2 };
expect(count).toStrictEqual(degreesOfSeparation);
});
And all tests pass. Now we can refactor the code to improve its readability and maintainability.
Code: src/domain/mocks/MockSocialNetworkGraphService.ts
First we could move the creation of the graph
object to a separate private method named buildGraph
inside the class.
export class MockSocialNetworkGraphService
implements SocialNetworkGraphService
{
...
#buildGraph(socialNetworkGraph: SocialNetworkGraph): Graph {
const { people, relationships } = socialNetworkGraph;
const graph: Graph = new Map<string, string[]>();
people.forEach((person) => {
graph.set(person.name, []);
});
relationships.forEach(({ type, startNode, endNode }) => {
if (type === "HasConnection") {
graph.get(startNode)?.push(endNode);
graph.get(endNode)?.push(startNode);
}
});
return graph;
}
...
}
The remaining code in the countDegreesOfSeparation
method could also be extract to two more private methods, calculateDegreeOfSeparation
and addNeighborsToQueue
:
export class MockSocialNetworkGraphService
implements SocialNetworkGraphService
{
...
#addNeighborsToQueue(
current: string,
degree: number,
graph: Graph,
visited: Set<string>,
queue: [string, number][],
) {
graph.get(current)?.forEach((neighbor) => {
if (!visited.has(neighbor)) {
queue.push([neighbor, degree + 1]);
}
});
}
#calculateDegreesOfSeparation(
person: string,
graph: Graph,
): DegreesOfSeparationCount {
const count: DegreesOfSeparationCount = { 1: 0, 2: 0 };
const visited = new Set<string>();
const queue: [string, number][] = [[person, 0]];
while (queue.length > 0) {
const [current, degree] = queue.shift()!;
if (visited.has(current)) continue;
visited.add(current);
if (degree > 0 && degree <= 2) {
count[degree as 1 | 2]++;
}
if (degree < 2) {
this.#addNeighborsToQueue(current, degree, graph, visited, queue);
}
}
return count;
}
countDegreesOfSeparation(
person: string,
socialNetworkGraph: SocialNetworkGraph,
): DegreesOfSeparationCount {
const graph = this.#buildGraph(socialNetworkGraph);
return this.#calculateDegreesOfSeparation(person, graph);
}
}
We have made the code more readable and used a Map
object to store the graph as handles large datasets efficiently with O(1) average time complexity for lookups and inserts.
Fixing the MockSocialNetworkGraphService over-implementation
The the recent steps we created a MockSocialNetworkGraphService
class that implements the SocialNetworkGraphService
interface and fully implemented the methods it required.
We didn't actually need to do that, we could (and probably should) have mocked the responses needed for the SocialNetworkGraphService
interface to fulfill each use case but I was trying to speed up the process of getting the tests to pass whilst implementing the code required for the SocialNetworkGraphService
.
We can adjust the test code very quickly so that is relies purely on mocking the expected response from the countDegreesOfSeparation
method of the SocialNetworkGraphService
interface.
Code: tests/domain/use-cases/CountDegressOfSeperation.spec.ts
import { jest } from '@jest/globals';
import { MockSocialNetworkGraphService } from '../mocks/MockSocialNetworkGraphService';
import { mockGraphOnePersonWithNoConnections } from '../mocks/graphResponseData';
import { CountDegreesOfSeparation } from '../../../src/domain/use-cases/CountDegressOfSeperation';
import { DegreesOfSeparationCount } from '../../../src/domain/interfaces/DegreesOfSeparationCount';
describe('CountConnectionsByDegreesOfSeparation', () => {
let mockSocialNetworkGraphService: MockSocialNetworkGraphService;
let countDegreesOfSeparation: CountDegreesOfSeparation;
beforeEach(() => {
jest.clearAllMocks();
mockSocialNetworkGraphService = new MockSocialNetworkGraphService();
countDegreesOfSeparation = new CountDegreesOfSeparation(mockSocialNetworkGraphService);
});
it('should return 2 connections at 1 degree and 1 connection at 2 degrees for John', () => {
const person = 'John';
const expectedCount: DegreesOfSeparationCount = { 1: 2, 2: 1 };
jest.spyOn(mockSocialNetworkGraphService, 'countDegreesOfSeparation').mockImplementation(() => {
return expectedCount;
});
const count = countDegreesOfSeparation.execute(person, mockGraphOnePersonWithNoConnections);
expect(count).toStrictEqual(expectedCount);
});
it('should return 3 connections at 1 degree and 0 connections at 2 degrees for Peter', () => {
const person = 'Peter';
const expectedCount: DegreesOfSeparationCount = { 1: 3, 2: 0 };
jest.spyOn(mockSocialNetworkGraphService, 'countDegreesOfSeparation').mockImplementation(() => {
return expectedCount;
});
const count = countDegreesOfSeparation.execute(person, mockGraphOnePersonWithNoConnections);
expect(count).toStrictEqual(expectedCount);
});
it('should return 2 connections at 1 degree and 1 connections at 2 degrees for George', () => {
const person = 'George';
const expectedCount: DegreesOfSeparationCount = { 1: 2, 2: 1 };
jest.spyOn(mockSocialNetworkGraphService, 'countDegreesOfSeparation').mockImplementation(() => {
return expectedCount;
});
const count = countDegreesOfSeparation.execute(person, mockGraphOnePersonWithNoConnections);
expect(count).toStrictEqual(expectedCount);
});
it('should return 0 connections at both 1 degree and 2 degrees for Harry', () => {
const person = 'Harry';
const expectedCount: DegreesOfSeparationCount = { 1: 0, 2: 0 };
jest.spyOn(mockSocialNetworkGraphService, 'countDegreesOfSeparation').mockImplementation(() => {
return expectedCount;
});
const count = countDegreesOfSeparation.execute(person, mockGraphOnePersonWithNoConnections);
expect(count).toStrictEqual(expectedCount);
});
it('should return 1 connections at 1 degree and 2 connections at 2 degrees for Anna', () => {
const person = 'Anna';
const expectedCount: DegreesOfSeparationCount = { 1: 1, 2: 2 };
jest.spyOn(mockSocialNetworkGraphService, 'countDegreesOfSeparation').mockImplementation(() => {
return expectedCount;
});
const count = countDegreesOfSeparation.execute(person, mockGraphOnePersonWithNoConnections);
expect(count).toStrictEqual(expectedCount);
});
});
We can then implement a test file for the SocialNetworkGraphServiceImpl.ts
file:
Code: tests/domain/services/SocialNetworkGraphServiceImpl.spec.ts
import { DegreesOfSeparationCount } from '../../../src/domain/interfaces/DegreesOfSeparationCount';
import { SocialNetworkGraphServiceImpl } from '../../../src/infrastructure/services/SocialNetworkGraphServiceImpl';
import { mockGraphOnePersonWithNoConnections } from '../../domain/mocks/graphResponseData';
describe('SocialNetworkGraphService', () => {
it('countPeopleWithNoConnections should return 1 for John', () => {
const socialNetworkGraphService = new SocialNetworkGraphServiceImpl();
const count = socialNetworkGraphService.countPeopleWithNoConnections(
mockGraphOnePersonWithNoConnections,
);
expect(count).toBe(1);
});
it('countDegreesOfSeparation should 2 connections at 1 degree and 1 connection at 2 degrees for John', () => {
const person = 'John';
const expectedCount: DegreesOfSeparationCount = { 1: 2, 2: 1 };
const socialNetworkGraphService = new SocialNetworkGraphServiceImpl();
const count = socialNetworkGraphService.countDegreesOfSeparation(
person,
mockGraphOnePersonWithNoConnections,
);
expect(count).toStrictEqual(expectedCount);
});
it('countDegreesOfSeparation should 3 connections at 1 degree and 0 connections at 2 degrees for Peter', () => {
const person = 'Peter';
const expectedCount: DegreesOfSeparationCount = { 1: 3, 2: 0 };
const socialNetworkGraphService = new SocialNetworkGraphServiceImpl();
const count = socialNetworkGraphService.countDegreesOfSeparation(
person,
mockGraphOnePersonWithNoConnections,
);
expect(count).toStrictEqual(expectedCount);
});
it('countDegreesOfSeparation should 2 connections at 1 degree and 1 connections at 2 degrees for George', () => {
const person = 'George';
const expectedCount: DegreesOfSeparationCount = { 1: 2, 2: 1 };
const socialNetworkGraphService = new SocialNetworkGraphServiceImpl();
const count = socialNetworkGraphService.countDegreesOfSeparation(
person,
mockGraphOnePersonWithNoConnections,
);
expect(count).toStrictEqual(expectedCount);
});
it('countDegreesOfSeparation should 0 connections at both 1 degree and 2 degrees for Harry', () => {
const person = 'Harry';
const expectedCount: DegreesOfSeparationCount = { 1: 0, 2: 0 };
const socialNetworkGraphService = new SocialNetworkGraphServiceImpl();
const count = socialNetworkGraphService.countDegreesOfSeparation(
person,
mockGraphOnePersonWithNoConnections,
);
expect(count).toStrictEqual(expectedCount);
});
it('countDegreesOfSeparation should 1 connections at 1 degree and 2 connections at 2 degrees for Anna', () => {
const person = 'Anna';
const expectedCount: DegreesOfSeparationCount = { 1: 1, 2: 2 };
const socialNetworkGraphService = new SocialNetworkGraphServiceImpl();
const count = socialNetworkGraphService.countDegreesOfSeparation(
person,
mockGraphOnePersonWithNoConnections,
);
expect(count).toStrictEqual(expectedCount);
});
});
And the actual implementation of the SocialNetworkGraphService.ts
interface in the SocialNetworkGraphServiceImpl.ts
file:
Code: src/infrastructure/services/SocialNetworkGraphServiceImpl.ts
import { SocialNetworkGraph } from '../../domain/interfaces/SocialNetworkGraph';
import { SocialNetworkGraphService } from '../../domain/services/SocialNetworkGraphService';
import { DegreesOfSeparationCount } from '../../domain/interfaces/DegreesOfSeparationCount';
type Graph = Map<string, string[]>;
export class SocialNetworkGraphServiceImpl implements SocialNetworkGraphService {
public countPeopleWithNoConnections(graph: SocialNetworkGraph): number {
const connectedPeople = new Set<string>();
for (const relationship of graph.relationships) {
const { type, startNode, endNode } = relationship;
if (type === 'HasConnection') {
connectedPeople.add(startNode);
connectedPeople.add(endNode);
}
}
return graph.people.filter((person) => !connectedPeople.has(person.name)).length;
}
#buildGraph(socialNetworkGraph: SocialNetworkGraph): Graph {
const { people, relationships } = socialNetworkGraph;
const graph: Graph = new Map<string, string[]>();
people.forEach((person) => {
graph.set(person.name, []);
});
relationships.forEach(({ type, startNode, endNode }) => {
if (type === 'HasConnection') {
graph.get(startNode)?.push(endNode);
graph.get(endNode)?.push(startNode);
}
});
return graph;
}
#addNeighborsToQueue(
current: string,
degree: number,
graph: Graph,
visited: Set<string>,
queue: [string, number][],
) {
graph.get(current)?.forEach((neighbor) => {
if (!visited.has(neighbor)) {
queue.push([neighbor, degree + 1]);
}
});
}
#calculateDegreesOfSeparation(person: string, graph: Graph): DegreesOfSeparationCount {
const count: DegreesOfSeparationCount = { 1: 0, 2: 0 };
const visited = new Set<string>();
const queue: [string, number][] = [[person, 0]];
while (queue.length > 0) {
const [current, degree] = queue.shift()!;
if (visited.has(current)) continue;
visited.add(current);
if (degree > 0 && degree <= 2) {
count[degree as 1 | 2]++;
}
if (degree < 2) {
this.#addNeighborsToQueue(current, degree, graph, visited, queue);
}
}
return count;
}
countDegreesOfSeparation(
person: string,
socialNetworkGraph: SocialNetworkGraph,
): DegreesOfSeparationCount {
const graph = this.#buildGraph(socialNetworkGraph);
return this.#calculateDegreesOfSeparation(person, graph);
}
}
And we can remove the full implemenation of each method from the MockSocialNetworkGraphService
class:
Code: tests/domain/mocks/MockSocialNetworkGraphService.ts
import { DegreesOfSeparationCount } from '../../../src/domain/interfaces/DegreesOfSeparationCount';
import { SocialNetworkGraphService } from '../../../src/domain/services/SocialNetworkGraphService';
import { SocialNetworkGraph } from '../../../src/domain/interfaces/SocialNetworkGraph';
export class MockSocialNetworkGraphService implements SocialNetworkGraphService {
public countPeopleWithNoConnections(graph: SocialNetworkGraph): number {
throw new Error('countPeopleWithNoConnections method not implemented.');
}
public countDegreesOfSeparation(
person: string,
socialNetworkGraph: SocialNetworkGraph,
): DegreesOfSeparationCount {
throw new Error('countDegreesOfSeparation method not implemented.');
}
}
We do not need to add private methods as they should not exist in the interface. We could however leave notes in the code to indicate the private methods exist for other developers to see.
Step 8: Red Phase - Write failing test for our Express Router
First we'll update our test
script to output more verbose logs:
"scripts": {
"test": "jest --watchAll --verbose"
},
This gives us a lot more detail:
PASS tests/domain/use-cases/CountPeopleWithNoConnections.spec.ts
CountPeopleWithNoConnections
✓ should return 1 when there is one person with no connections (3 ms)
✓ should return 0 when there are no people with no connections
PASS tests/infrastructure/services/SocialNetworkGraphService.spec.ts
SocialNetworkGraphService
✓ countPeopleWithNoConnections should return 1 for John (2 ms)
✓ countDegreesOfSeparation should 2 connections at 1 degree and 1 connection at 2 degrees for John (1 ms)
✓ countDegreesOfSeparation should 3 connections at 1 degree and 0 connections at 2 degrees for Peter (1 ms)
✓ countDegreesOfSeparation should 2 connections at 1 degree and 1 connections at 2 degrees for George
✓ countDegreesOfSeparation should 0 connections at both 1 degree and 2 degrees for Harry
✓ countDegreesOfSeparation should 1 connections at 1 degree and 2 connections at 2 degrees for Anna
PASS tests/domain/use-cases/CountDegressOfSeperation.spec.ts
CountConnectionsByDegreesOfSeparation
✓ should return 2 connections at 1 degree and 1 connection at 2 degrees for John (3 ms)
✓ should return 3 connections at 1 degree and 0 connections at 2 degrees for Peter (1 ms)
✓ should return 2 connections at 1 degree and 1 connections at 2 degrees for George
✓ should return 0 connections at both 1 degree and 2 degrees for Harry
✓ should return 1 connections at 1 degree and 2 connections at 2 degrees for Anna
-----------------------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------------------------------|---------|----------|---------|---------|-------------------
All files | 100 | 85 | 100 | 100 |
domain/use-cases | 100 | 100 | 100 | 100 |
CountDegressOfSeperation.ts | 100 | 100 | 100 | 100 |
CountPeopleWithNoConnections.ts | 100 | 100 | 100 | 100 |
infrastructure/services | 100 | 85 | 100 | 100 |
SocialNetworkGraphServiceImpl.ts | 100 | 85 | 100 | 100 | 33-48
-----------------------------------|---------|----------|---------|---------|-------------------
Test Suites: 3 passed, 3 total
Tests: 13 passed, 13 total
Snapshots: 0 total
Time: 1.897 s
Ran all test suites.
Next can now write a failing test for our Express Router.
Let's install the dependencies for the test:
npm i express
npm i -D @types/express supertest @types/supertest
We need to add esModuleInterop
to the tsconfig.json
file:
{
"compilerOptions": {
"target": "ES6",
"module": "commonjs",
"strict": true,
"outDir": "./dist",
"esModuleInterop": true
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["node_modules"]
}
So that we can use ES6 modules in our server.ts
file.
import express from 'express';
const server = express();
server.use(express.json());
export default server;
Code: tests/api/routes/SocialNetworkGraph.spec.ts