PL EN

Wzorzec projektowy Strategia

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