Na przykładzie kodu w TypeScript pokażę Ci jak zastosować wzorzec Strategii. Na omawianym przypadku w celu obsłużenia różnego źródła przechowywania danych odwołuję się do danych odpowiednio z localStorage lub bazy danych.
Wzorzec Strategia umożliwia zmianę zachowania obiektu w czasie działania programu, bez konieczności modyfikowania kodu. Polega na definiowaniu zestawu algorytmów (strategii), które można zamieniać w trakcie działania aplikacji.
Główna klasa StorageContext
, wybiera odpowiednią strategię na podstawie propsa przekazanego do konstruktora klasy. Strategia zmienia się dynamicznie za pomocą metody setStrategy
pobierając odpowiednio nowe dane.
export interface StorageStrategy {
getTasks: () => Promise<Response<Task[]>>;
addTask: (task: Task) => Promise<Response<Task>>;
editTask: (
taskId: string,
updatedTask: Partial<Task>,
) => Promise<Response<Task>>;
deleteTask: (taskId: string) => Promise<Response>;
deleteAllTasks: () => Promise<Response>;
}
// StorageContext.ts
export class StorageContext {
private strategy: StorageStrategy;
constructor(strategy: StorageStrategy) {
this.strategy = strategy;
}
setStrategy(strategy: StorageStrategy) {
this.strategy = strategy;
}
async getTasks(): Promise<Response<Task[]>> {
return this.strategy.getTasks();
}
async addTask(task: Task): Promise<Response<Task>> {
return this.strategy.addTask(task);
}
async editTask(
taskId: string,
updatedTask: Partial<Task>,
): Promise<Response<Task>> {
return this.strategy.editTask(taskId, updatedTask);
}
async deleteTask(taskId: string): Promise<Response> {
return this.strategy.deleteTask(taskId);
}
async deleteAllTasks(): Promise<Response> {
return this.strategy.deleteAllTasks();
}
}
Dzięki przygotowanemu kontekstowi strategii możemy utworzyć logikę pobierania danych z wybranych źródeł.
W omawianym przykładzie nie skupiam się na tworzeniu pełnej logiki kodu, a wyłącznie na pokazaniu użycia wzorca.
// LocalStorageStrategy.ts
export class LocalStorageStrategy implements StorageStrategy {
private readonly STORAGE_KEY = ''; // unique key
private saveToLocalStorage(tasks: Task[]) {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(tasks));
}
private clearLocalStorage() {
localStorage.removeItem(this.STORAGE_KEY);
}
async getTasks(): Promise<Response<Task[]>> {
// getTasks function
}
async addTask(task: Task): Promise<Response<Task>> {
// addTask function
}
async editTask(
taskId: string,
updatedTask: Partial<Task>,
): Promise<Response<Task>> {
// editTask function
}
async deleteTask(taskId: string): Promise<Response> {
// deleteTask function
}
async deleteAllTasks(): Promise<Response> {
// deleteAllTasks function
}
}
Podobny plik należy utworzyć dla DbStorageStrategy
z funkcjami komunikującymi się z endpointami backendu.
// DbStorageStrategy.ts
export class DbStorageStrategy implements StorageStrategy {
async getTasks(): Promise<Response<Task[]>> {
// getTasks function
}
async addTask(task: Task): Promise<Response<Task>> {
// addTask function
}
async editTask(
taskId: string,
updatedTask: Partial<Task>,
): Promise<Response<Task>> {
// editTask function
}
async deleteTask(taskId: string): Promise<Response> {
// deleteTask function
}
async deleteAllTasks(): Promise<Response> {
// deleteAllTasks function
}
}
Tworzenie poszczególnych strategii można opakować w dodatkową warstwę abstrakcji, np. z użyciem wzorca Fabryka, zapewniając zgodność kodu z zasadą Open/Closed Principle.
Wzorzec Strategii ukrywa szczegóły implementacyjne algorytmów, które implementują określony interfejs. Ułatwia utrzymanie i zwiększa elastyczność kodu.
Testy jednostkowe dla LocalStorageStrategy w Vitest
/// LocalStorageStrategy.test.ts
const getStorageSpy = vi.spyOn(Storage.prototype, 'getItem');
const setStorageSpy = vi.spyOn(Storage.prototype, 'setItem');
describe('LocalStorageStrategy', () => {
let strategy = new LocalStorageStrategy();
afterEach(() => {
localStorage.clear();
getStorageSpy.mockClear();
setStorageSpy.mockClear();
});
it('should fetch tasks from localStorage', async () => {
await strategy.getTasks();
expect(getStorageSpy).toHaveBeenCalledWith(LOCAL_STORAGE_KEY);
});
it('should add a task to localStorage', async () => {
await strategy.addTask(mockedTasks[0]);
expect(setStorageSpy).toHaveBeenCalledWith(
LOCAL_STORAGE_KEY,
JSON.stringify([mockedTasks[0]]),
);
});
it('should edit a task in localStorage', async () => {
await strategy.addTask(mockedTasks[0]);
const updatedTask = { title: 'Updated Task' };
await strategy.editTask(mockedTasks[0]._id, updatedTask);
expect(setStorageSpy).toHaveBeenCalledWith(
LOCAL_STORAGE_KEY,
JSON.stringify([{ ...mockedTasks[0], title: 'Updated Task' }]),
);
});
it('should delete a task from localStorage', async () => {
await strategy.addTask(mockedTasks[0]);
await strategy.deleteTask(mockedTasks[0]._id);
await strategy.getTasks();
expect(setStorageSpy).toHaveBeenCalledWith(LOCAL_STORAGE_KEY, JSON.stringify([]));
});
it('should delete all tasks from localStorage', async () => {
await strategy.addTask(mockedTasks[0]);
await strategy.addTask(mockedTasks[1]);
await strategy.addTask(mockedTasks[2]);
await strategy.deleteAllTasks();
await strategy.getTasks();
expect(setStorageSpy).toHaveBeenCalledWith(LOCAL_STORAGE_KEY, JSON.stringify([]));
});
});
Metody strategii komunikujące się z backendem wcześniej należy zamockować, aby testować oczekiwane zachowanie funkcji. Na moment publikacji artykułu polecam w tym celu bibliotekę MSW.
Testy jednostkowe dla DbStorageStretegy w Vitest
/// DbStorageStrategy.test.ts
describe('DbStorageStrategy', () => {
let strategy: DbStorageStrategy;
beforeEach(() => {
vi.clearAllMocks();
strategy = new DbStorageStrategy();
});
it('should get tasks from API', async () => {
const result = await strategy.getTasks();
expect(result.object).toHaveLength(3);
expect(result.object[0]).toEqual(mockedTasks[0]);
});
it('should add task to API', async () => {
const result = await strategy.addTask(newTask);
expect(result.object).toEqual(newTask);
});
it('should edit task from API', async () => {
const result = await strategy.editTask('1', { title: 'New Title' });
expect(result.object).toEqual({ ...mockedTasks[0], title: 'New Title' });
});
it('should delete task from API', async () => {
const result = await strategy.deleteTask('1');
expect(result.message).toEqual('Task deleted');
});
it('should delete all tasks from API', async () => {
const result = await strategy.deleteAllTasks();
expect(result.message).toEqual('Tasks deleted');
});
});
Zalety:
- Możliwość dodawania nowych strategii
- Poszczególna strategia odpowiada za własną implementację logiki
- Dynamiczna zmiana zachowania obiektu w trakcie działania aplikacji
Wady:
- Każda strategia wymaga tworzenia oddzielnej klasy
- Utworzenie dodatkowych klas i interfejsów
- Komplikacja rozwiązana w przypadku mniejszych aplikacji
Powrót do artykułów