01 - Intro

Definizione

Un design pattern è una strategia per la soluzione di problemi ricorrenti di software design.

A differenza di un algoritmo, che definisce una serie di istruzioni fisse per risolvere un problema, un pattern definisce l’approccio per arrivare alla soluzione.

Una ricetta con le istruzioni passo passo è un algoritmo, uno schema tecnico è un pattern: definisce il risultato e le caratteristiche ma non i passaggi per implementarlo.

Il concetto di Design Pattern è stato formalizzato nel 1994 da 4 sviluppatori (gang of 4) C++ nel libro Design Patterns. Il libro descrive 23 approcci (patterns) per la soluzione di problemi ricorrenti nello sviluppo software. Il libro divide i pattern in 3 categorie: creational, structural e behavioral. Anche se il libro è completamente incentrato sulla programmazione ad oggetti gli stessi principi si possono applicare anche ad altri stili di programmazione.

Una risorsa molto utile a cui faremo riferimento e da cui ho preso anche molto del materiale di questo corso è il sito refactoring.guru, e il loro libro.

Caratteristiche di un buon codice

Ci sono alcuni obiettivi a cui si mira quando si sviluppa un codice, obiettivi che se raggiunti permettono di scrivere codice più mantenibile, più testabile e più semplice da evolvere nel tempo.

Code reuse

Non duplicare il codice è sempre una buona prassi per ridurre sia il tempo di sviluppo sia gli errori.

Il rovescio della medaglia è che per rendere riutilizzabile un pezzo di codice spesso è necessario fare più lavoro rispetto a un semplice copia/incolla:

  • analizzare dove il codice può essere utilizzato
  • identificare se ci sono più modi in cui si deve comportare a seconda del contesto
  • prevedere come qualcuno potrebbe usare il codice in futuro e renderlo resistente ad eventuali casi limite

Conoscere i design pattern più comuni e adottare quelli più idonei può ridurre considerevolmente il tempo necessario a scrivere codice riutilizzabile.

Extensibility

Un software non rimarrà mai lo stesso, e se lo fa qualcuno ne scriverà uno nuovo e migliore per rimpiazzarlo.

A volte arrivano nuove specifiche da un cliente, altre cambiano le API di un servizio terzo, altre ancora cambiano delle regolamentazioni per cui è necessario modificare un comportamento, ecc.

Scrivere un codice che sia più flessibile al cambiamento è un costo all'inizio, ma paga sul lungo termine.

Anche qui applicare i giusti design pattern permette di minimizzare le modifiche da fare al codice nel momento in cui è necessario un cambiamento.

Testability

La testabilità di un codice indica quando facilmente e accuratamente è possibile testarne in modo automatico il funzionamento.

Testare un programma è importante principalmente per due motivi:

  • verificare che si comporti correttamente sia nei casi normali che in quelli limite
  • verificare che continui a comportarsi normalmente anche dopo che è stato modificato

Testare un programma occupa un enorme tempo di sviluppo, ma è ancora più difficile da fare se il programma non è scritto rispettando determinati principi. Tendenzialmente un codice in cui le varie componenti sono eccessivamente legate tra di loro rende difficile la scrittura di unit test.
Anche in questo caso applicare i giusti design pattern permette di scomporre il codice in componenti logiche più semplici da testare individualmente.

Esempi

partire da repo base

Illustrare il progetto:

  • usa nestjs, è un framework che segue delle logiche simili a quelli di angular, è costruito sopra a expess e ci mette a disposizione un motore di DI, che tornerà utile per vedere sfruttare alcuni dei pattern
  • stiamo andando a creare un semplice timetracker
  • c'è già mongo configurato
  • per il momento ci sono solo alcune entità e un controller che crea, lista e mostra un singolo risultato
  • ogni time-entry può essere fatturabile o meno, il valore economico viene calcolato a runtime

Quali sono i problemi già evidenti con questo codice?

  • abbiamo codice duplicato (calcolo della durata, calcolo del valore)
  • ci sono dei parametri ripetuti e scritti hardcoded (valore orario)
  • il controller contiene tutta la logica
  • è difficile da testare perché racchiude tutto il funzionamento

Facciamo i primi miglioramenti:

  • creiamo un servizio che torna i dati

time-entry.ds.service.ts

import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { TimeEntry } from './time-entry.schema';
import { Model, Types } from 'mongoose';
import { CreateTimeEntryDTO } from './time-entry.dto';

@Injectable()
export class TimeEntryDataSource {
  constructor(@InjectModel(TimeEntry.name) private readonly timeEntryModel: Model<TimeEntry>) {}

  async list(): Promise<TimeEntry[]> {
    return this.timeEntryModel.find().then(records => records.map(r => r.toObject()));
  }

  async get(id: Types.ObjectId | string): Promise<TimeEntry> {
    return this.timeEntryModel.findById(id).then(record => record.toObject());
  }

  async add(data: CreateTimeEntryDTO): Promise<TimeEntry> {
    const record = await this.timeEntryModel.create(data);
    return record.toObject();
  }

}

time-entry.controller.ts

import { TimeEntryDataSource } from './time-entry-datasource.service';
import {
  Body,
  Controller,
  Get,
  HttpException,
  HttpStatus,
  Param,
  Post,
  UsePipes,
  ValidationPipe,
} from '@nestjs/common';
import { CalculatedTimeEntry } from './time-entry.entity';
import { CreateTimeEntryDTO } from './time-entry.dto';

@Controller('time-entries')
export class TimeEntryController {
  constructor(protected readonly dataSorce: TimeEntryDataSource) {}

  @Get()
  async list(): Promise<CalculatedTimeEntry[]> {
    const list = await this.dataSorce.list();

    return list.map((e) => {
      const duration = (e.end.getTime() - e.start.getTime()) / (1000 * 60 * 60);
      return {
        ...e,
        amount: e.billable ? duration * 60 : 0,
      };
    });
  }

  @Get(':id')
  async detail(@Param('id') id: string) {
    const record = await this.dataSorce.get(id);
    if (!record) {
      throw new HttpException('Not fount', HttpStatus.NOT_FOUND);
    }
    const duration = (record.end.getTime() - record.start.getTime()) / (1000 * 60 * 60);
    return {
      ...record,
      amount: record.billable ? duration * 60 : 0,
    };
  }

  @Post()
  @UsePipes(new ValidationPipe({transform: true}))
  async create(@Body() createTimeEntryDTO: CreateTimeEntryDTO) {
    const record = await this.dataSorce.add(createTimeEntryDTO);
  
    const duration = (record.end.getTime() - record.start.getTime()) / (1000 * 60 * 60);
    return {
      ...record,
      amount: record.billable ? duration * 60 : 0,
    };
  }
}

Proviamo adesso a scrivere un test sul controller:
Quando facciamo unit test un componente dobbiamo partire dal presupposto che le sue dipendenze siano già state testate, e quindi andare a verificare il suo risultato.
Andiamo a creare un finto servizio datasource con cui sostituire quello reale in fase di test, questo ci verrà comodo sia per gli unit test sia per quelli e2e.

time-entry.ds.mock.ts

import { Injectable } from '@nestjs/common';
import { TimeEntry } from '../time-entry.schema';
import { Types } from 'mongoose';
import { CreateTimeEntryDTO } from '../time-entry.dto';

@Injectable()
export class TimeEntryMockDataSource {
  private data: TimeEntry[] = [];

  constructor(data: TimeEntry[] = []) {
    this.data = data;
  }

  setRecords(data: TimeEntry[]) {
    this.data = data;
  }

  async list(): Promise<TimeEntry[]> {
    return this.data;
  }

  async get(id: Types.ObjectId | string): Promise<TimeEntry> {
    return this.data.find(e => e.id.equals(id));
  }

  async add(data: CreateTimeEntryDTO): Promise<TimeEntry> {
    const id = new Types.ObjectId();
    const record = { ...data, id};
    this.data.push(record);
    return record;
  }

}

Grazie a questa classe abbiamo a disposizione un datasource che ci permette di settare i dati a nostro piacimento invece di andare a prenderli da db. Questo in fase di test è utile per due motivi:

  • possiamo controllare il comportamento senza prima dover controllare lo stato del database
  • i test di default girano in parallelo, staccandoli dal db abbiamo la possibilità di eseguirli in contemporanea mantenendo il controllo dei dati

Andiamo a scriver il nostro primo test:
time-entry.controller.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { TimeEntryController } from './time-entry.controller';
import { TimeEntryDataSource } from './time-entry.ds.service';
import { TimeEntryMockDataSource } from './mocks/time-entry.ds.mock';
import { Types } from 'mongoose';
import { TimeEntry } from './time-entry.schema';

describe('TimeEntryController', () => {
  let controller: TimeEntryController;
  let dataSource: MockTimeEntryDataSource;

  beforeEach(async () => {
    dataSource = new TimeEntryMockDataSource();
    const app: TestingModule = await Test.createTestingModule({
      controllers: [TimeEntryController],
      providers: [{
        provide: TimeEntryDataSource,
        useValue: dataSource
      }],
    }).compile();

    controller = app.get<TimeEntryMockDataSource>(TimeEntryController);

  });

  describe('list',  () => {
    it('should return a list of elements with amount"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date(),
          end: new Date(),
          billable: true
        },
        {
          id: new Types.ObjectId(),
          description: 'Test2',
          start: new Date(),
          end: new Date(),
          billable: true
        }
      ];
      dataSource.setRecords(records);

      return controller.list().then(result => {
        expect(result.length).toBe(records.length);
      })
    });

    it('should calculate billable amounts"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date('2024-01-10T10:00:00.000Z'),
          end: new Date('2024-01-10T11:00:00.000Z'),
          billable: true
        },
        {
          id: new Types.ObjectId(),
          description: 'Test2',
          start: new Date('2024-01-10T11:00:00.000Z'),
          end: new Date('2024-01-10T13:00:00.000Z'),
          billable: false
        },

        {
          id: new Types.ObjectId(),
          description: 'Test2',
          start: new Date('2024-01-13T10:00:00.000Z'),
          end: new Date('2024-01-14T10:00:00.000Z'),
          billable: true
        }
      ];
      dataSource.setRecords(records);

      return controller.list().then(result => {      
        expect(result[0].amount).toBeGreaterThan(0);
        expect(result[1].amount).toBe(0);
        expect(result[2].amount).toBeGreaterThan(0);
      })

    });
  });

  describe('detail', () => {
    it('should return a single record with amount"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date(),
          end: new Date(),
          billable: true
        },
        {
          id: new Types.ObjectId(),
          description: 'Test2',
          start: new Date(),
          end: new Date(),
          billable: true
        }
      ];
      dataSource.setRecords(records);

      return controller.detail(records[1].id.toString()).then(result => {
        expect(result.id).toBe(records[1].id);
        expect(result.amount).toBeDefined();
      })
    });

    it('should calculate billable amounts"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date('2024-01-10T10:00:00.000Z'),
          end: new Date('2024-01-10T11:00:00.000Z'),
          billable: true
        }
      ];
      dataSource.setRecords(records);

      return controller.detail(records[0].id.toString()).then(result => {
        expect(result.amount).toBeGreaterThan(0);
      })
    });
    it('should leave non billable amounts to 0"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date('2024-01-10T10:00:00.000Z'),
          end: new Date('2024-01-10T11:00:00.000Z'),
          billable: false
        }
      ];
      dataSource.setRecords(records);

      return controller.detail(records[0].id.toString()).then(result => {
        expect(result.amount).toBe(0);
      })
    });

    it('should throw an exception if not found"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date('2024-01-10T10:00:00.000Z'),
          end: new Date('2024-01-10T11:00:00.000Z'),
          billable: false
        }
      ];
      dataSource.setRecords(records);
      return expect(controller.detail('test')).rejects.toThrow('Not found');
    });

  });

  describe('create', () => {
    it('should add a new billable record', async () => {
      const record = {
        description: 'Test1',
        start: new Date('2024-01-10T10:00:00.000Z'),
        end: new Date('2024-01-10T11:00:00.000Z'),
        billable: true
      }
      return controller.create(record).then(result =>{
        expect(result.id).toBeDefined();
        expect(result.description).toBe(record.description);
        expect(result.billable).toBe(true);
        expect(result.amount).toBeGreaterThan(0);
      })
    });

    it('should add a new non billable record', async () => {
      const record = {
        description: 'Test1',
        start: new Date('2024-01-10T10:00:00.000Z'),
        end: new Date('2024-01-10T11:00:00.000Z'),
        billable: false
      }
      return controller.create(record).then(result =>{
        expect(result.id).toBeDefined();
        expect(result.description).toBe(record.description);
        expect(result.billable).toBe(false);
        expect(result.amount).toBe(0);
      })
    });
  })
});


Vedete che possiamo chiamare il metodo setRecords durante l'esecuzione per controllare i dati e verificare i risultati

Facciamo un ulteriore miglioramento al codice andando a creare una classe astratta per il nostro datasource, le due classi che abbiamo andranno poi ad implementare quella classe, in questo modo siamo sicuri che il compilatore ci darà errore se facciamo delle modifiche a una delle due e non le riportiamo anche sull'altra classe.
Normalmente sarebbe sufficiente definire un'interfaccia, Nest però (come angular) non accetta interfacce come Token dei provider (perché poi quando il codice viene compilato in javascript le interfacce scompaiono) e quindi ci serve una classe astratta.

time-entry.ds.service.ts

import { CreateTimeEntryDTO } from "./time-entry.dto";
import { TimeEntry } from "./time-entry.schema";
import { Types } from 'mongoose';

export abstract class TimeEntryDataSource {
  abstract list(): Promise<TimeEntry[]>;

  abstract get(id: Types.ObjectId | string): Promise<TimeEntry>;

  abstract add(data: CreateTimeEntryDTO): Promise<TimeEntry>
}

Rinominiamo la classe che usa mongo e chiamiamola TimeEntryMockDataSrouce.
Sia la classe con mongo sia quella di mock devono estendere la classe astratta.
Aggiorniamo sia il module dell'app sia quello di test.

Se adesso andiamo a provare a fare una modifica a una delle classi che non è compatibile con il datasource typescript ci darà errore di compilazione. Inoltre, usando la definizione della classe astratta nel controller ci assicuriamo di non usare solo metodi che sono comuni a tutte le implementazioni.

Notare anche che i test continuano a funzionare come prima.

Adesso che abbiamo i nostri test andiamo a migliorare ancora il nostro codice, forti del fatto che se rompiamo qualcosa i test ce lo diranno.

Andiamo a fare il servizio per calcolare la durata:

  • anche in questo caso andiamo a definire prima una classe astratta e poi l'implementazione specifica (vedremo poi a cosa ci servirà)

duration.service.ts

export abstract class TimeEntryDurationService {
  abstract getDuration(start: Date, end: Date): number;
}

exact-duration.service.ts

import { Injectable } from "@nestjs/common";
import { TimeEntryDurationService } from "./duration.service";

@Injectable()
export class ExactTimeEntryDurationService extends TimeEntryDurationService {
  getDuration(start: Date, end: Date): number {
      const millis = end.getTime() - start.getTime();
      return millis / (1000 * 60 * 60);
  }
}

Andiamo a sostituire il codice nel controller e nei moduli di app e test.

Scriviamo anche i test del servizio che abbiamo creato:
exact-duration.service.spec.ts

import { ExactTimeEntryDurationService } from "./exact-duration.service"

describe('ExactDurationService', () => {
  let srv: ExactTimeEntryDurationService;

  beforeEach(() => {
    srv = new ExactTimeEntryDurationService();
  })
  it('should calculate 0.5h', () => {
    const start = new Date('2024-01-10T10:00:00.000Z');
    const end = new Date('2024-01-10T10:30:00.000Z');
    const res = srv.getDuration(start, end);
    expect(res).toBe(0.5);
  });

  it('should calculate 1h', () => {
    const start = new Date('2024-01-10T10:00:00.000Z');
    const end = new Date('2024-01-10T11:00:00.000Z');
    const res = srv.getDuration(start, end);
    expect(res).toBe(1);
  });

  it('should calculate 0h', () => {
    const start = new Date('2024-01-10T10:00:00.000Z');
    const end = new Date('2024-01-10T10:00:00.000Z');
    const res = srv.getDuration(start, end);
    expect(res).toBe(0);
  });
});

Qui cominciamo a vedere l'utilità dei test automatici:

  • se modifichiamo il calcolo senza rendercene conto si rompono
  • ci permettono di ragionare su alcuni casi limite della funzione che prima non ci sembravano plausibili in mezzo al resto del codice, ad esempio cosa succede se end è minore di start? Torniamo il numero negativo? è il caso di lanciare un errore?

Adesso che la nostra funzione è testata cosa dobbiamo occuparci di controllare sui test del controller? Solo che venga chiamata con i parametri giusti.

import { Test, TestingModule } from '@nestjs/testing';
import { TimeEntryController } from './time-entry.controller';
import { TimeEntryDataSource } from './datasource/time-entry.ds.service';
import { TimeEntryMockDataSource } from './mocks/time-entry.ds.mock.service';
import { Types } from 'mongoose';
import { TimeEntry } from './entities/time-entry.schema';
import { ExactTimeEntryDurationService } from './duration/exact-duration.service';
import { TimeEntryDurationService } from './duration/duration.service';

describe('TimeEntryController', () => {
  let controller: TimeEntryController;
  let dataSource: TimeEntryMockDataSource;
  let durationSrv: TimeEntryDurationService;

  beforeEach(async () => {
    dataSource = new TimeEntryMockDataSource();
    const app: TestingModule = await Test.createTestingModule({
      controllers: [TimeEntryController],
      providers: [{
        provide: TimeEntryDataSource,
        useValue: dataSource
      },
      {provide: TimeEntryDurationService, useClass: ExactTimeEntryDurationService}
    ],
    }).compile();

    controller = app.get<TimeEntryController>(TimeEntryController);
    durationSrv = app.get<TimeEntryDurationService>(TimeEntryDurationService);

  });

  describe('list',  () => {
    it('should return a list of elements with amount"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date(),
          end: new Date(),
          billable: true
        },
        {
          id: new Types.ObjectId(),
          description: 'Test2',
          start: new Date(),
          end: new Date(),
          billable: true
        }
      ];
      dataSource.setRecords(records);
      
      return controller.list().then(result => {  
        expect(result.length).toBe(records.length);
      })
    });

    it('should calculate billable amounts"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date('2024-01-10T10:00:00.000Z'),
          end: new Date('2024-01-10T11:00:00.000Z'),
          billable: true
        },
        {
          id: new Types.ObjectId(),
          description: 'Test2',
          start: new Date('2024-01-10T11:00:00.000Z'),
          end: new Date('2024-01-10T13:00:00.000Z'),
          billable: false
        },

        {
          id: new Types.ObjectId(),
          description: 'Test2',
          start: new Date('2024-01-13T10:00:00.000Z'),
          end: new Date('2024-01-14T10:00:00.000Z'),
          billable: true
        }
      ];
      dataSource.setRecords(records);
      const durationSpy = jest.spyOn(durationSrv, 'getDuration');
      return controller.list().then(result => {
        expect(durationSpy).toHaveBeenCalledTimes(records.length);
        for(let i = 0; i < records.length; i++) {
          expect(durationSpy).toHaveBeenNthCalledWith(i + 1, records[i].start, records[i].end)
        }
        expect(result[0].amount).toBeGreaterThan(0);
        expect(result[1].amount).toBe(0);
        expect(result[2].amount).toBeGreaterThan(0);
      })

    });
  });

  describe('detail', () => {
    it('should return a single record with amount"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date(),
          end: new Date(),
          billable: true
        },
        {
          id: new Types.ObjectId(),
          description: 'Test2',
          start: new Date(),
          end: new Date(),
          billable: true
        }
      ];
      dataSource.setRecords(records);

      return controller.detail(records[1].id.toString()).then(result => {
        expect(result.id).toBe(records[1].id);
        expect(result.amount).toBeDefined();
      })
    });

    it('should calculate billable amounts"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date('2024-01-10T10:00:00.000Z'),
          end: new Date('2024-01-10T11:00:00.000Z'),
          billable: true
        }
      ];
      dataSource.setRecords(records);

      return controller.detail(records[0].id.toString()).then(result => {
        expect(result.amount).toBeGreaterThan(0);
      })
    });
    it('should leave non billable amounts to 0"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date('2024-01-10T10:00:00.000Z'),
          end: new Date('2024-01-10T11:00:00.000Z'),
          billable: false
        }
      ];
      dataSource.setRecords(records);

      return controller.detail(records[0].id.toString()).then(result => {
        expect(result.amount).toBe(0);
      })
    });

    it('should throw an exception if not found"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date('2024-01-10T10:00:00.000Z'),
          end: new Date('2024-01-10T11:00:00.000Z'),
          billable: false
        }
      ];
      dataSource.setRecords(records);
      return expect(controller.detail('test')).rejects.toThrow('Not found');
    });

  });

  describe('create', () => {
    it('should add a new billable record', async () => {
      const record = {
        description: 'Test1',
        start: new Date('2024-01-10T10:00:00.000Z'),
        end: new Date('2024-01-10T11:00:00.000Z'),
        billable: true
      }
      return controller.create(record).then(result =>{
        expect(result.id).toBeDefined();
        expect(result.description).toBe(record.description);
        expect(result.billable).toBe(true);
        expect(result.amount).toBeGreaterThan(0);
      })
    });

    it('should add a new non billable record', async () => {
      const record = {
        description: 'Test1',
        start: new Date('2024-01-10T10:00:00.000Z'),
        end: new Date('2024-01-10T11:00:00.000Z'),
        billable: false
      }
      return controller.create(record).then(result =>{
        expect(result.id).toBeDefined();
        expect(result.description).toBe(record.description);
        expect(result.billable).toBe(false);
        expect(result.amount).toBe(0);
      })
    });
  })
});

Andiamo avanti ancora un po':
Guidatemi voi per il calcolo di amount
amount.service.ts

export abstract class TimeEntryAmountService {
  abstract calcAmount(duration: number);
}

fixed.amount.service.ts

import { Injectable } from "@nestjs/common";
import { TimeEntryAmountService } from "./amount.service";

@Injectable()
export class FixedAmountService extends TimeEntryAmountService {
  calcAmount(duration: number) {
      return duration * 60;
  }
}

fixed.amount.service.spec.ts

import { FixedAmountService } from "./fixed-amount.service"

describe('FixedAmountService', () => {
  let amountSrv: FixedAmountService;
  beforeAll(() => {
    amountSrv = new FixedAmountService();
  })
  it('should return 30', () => {
    const amount = amountSrv.calcAmount(0.5);
    expect(amount).toBe(30);
  })

  it('should return 60', () => {
    const amount = amountSrv.calcAmount(1);
    expect(amount).toBe(60);
  })

  it('should return 72', () => {
    const amount = amountSrv.calcAmount(1.2);
    expect(amount).toBe(72);
  })

  it('should return 0', () => {
    const amount = amountSrv.calcAmount(0);
    expect(amount).toBe(0);
  })
})

time-entry.controller.ts

import { TimeEntryDataSource } from './datasource/time-entry.ds.service';
import {
  Body,
  Controller,
  Get,
  HttpException,
  HttpStatus,
  Param,
  Post,
  UsePipes,
  ValidationPipe,
} from '@nestjs/common';
import { CalculatedTimeEntry } from './entities/time-entry.entity';
import { CreateTimeEntryDTO } from './entities/time-entry.dto';
import { TimeEntryDurationService } from './duration/duration.service';
import { TimeEntryAmountService } from './amount/amount.service';

@Controller('time-entries')
export class TimeEntryController {
  constructor(
    protected readonly dataSorce: TimeEntryDataSource,
    protected readonly durationSrv: TimeEntryDurationService,
    protected readonly amountSrv: TimeEntryAmountService) {}

  @Get()
  async list(): Promise<CalculatedTimeEntry[]> {
    const list = await this.dataSorce.list();

    return list.map((e) => {
      const duration = this.durationSrv.getDuration(e.start, e.end);
      return {
        ...e,
        amount: e.billable ? this.amountSrv.calcAmount(duration) : 0,
      };
    });
  }

  @Get(':id')
  async detail(@Param('id') id: string) {
    const record = await this.dataSorce.get(id);
    if (!record) {
      throw new HttpException('Not found', HttpStatus.NOT_FOUND);
    }
    const duration = this.durationSrv.getDuration(record.start, record.end);
    return {
      ...record,
      amount: record.billable ? this.amountSrv.calcAmount(duration) : 0,
    };
  }

  @Post()
  @UsePipes(new ValidationPipe({transform: true}))
  async create(@Body() createTimeEntryDTO: CreateTimeEntryDTO) {
    const record = await this.dataSorce.add(createTimeEntryDTO);
  
    const duration = this.durationSrv.getDuration(record.start, record.end);
    return {
      ...record,
      amount: record.billable ? this.amountSrv.calcAmount(duration) : 0,
    };
  }
}

time-entry.controller.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { TimeEntryController } from './time-entry.controller';
import { TimeEntryDataSource } from './datasource/time-entry.ds.service';
import { TimeEntryMockDataSource } from './mocks/time-entry.ds.mock.service';
import { Types } from 'mongoose';
import { TimeEntry } from './entities/time-entry.schema';
import { ExactTimeEntryDurationService } from './duration/exact-duration.service';
import { TimeEntryDurationService } from './duration/duration.service';
import { FixedAmountService } from './amount/fixed-amount.service';
import { TimeEntryAmountService } from './amount/amount.service';

describe('TimeEntryController', () => {
  let controller: TimeEntryController;
  let dataSource: TimeEntryMockDataSource;
  let durationSrv: TimeEntryDurationService;
  let amountSrv: TimeEntryAmountService;

  beforeEach(async () => {
    dataSource = new TimeEntryMockDataSource();
    const app: TestingModule = await Test.createTestingModule({
      controllers: [TimeEntryController],
      providers: [{
        provide: TimeEntryDataSource,
        useValue: dataSource
      },
      {provide: TimeEntryDurationService, useClass: ExactTimeEntryDurationService},
      {provide: TimeEntryAmountService, useClass: FixedAmountService}
    ],
    }).compile();

    controller = app.get<TimeEntryController>(TimeEntryController);
    durationSrv = app.get<TimeEntryDurationService>(TimeEntryDurationService);
    amountSrv = app.get<TimeEntryAmountService>(TimeEntryAmountService);
  });

  describe('list',  () => {
    it('should return a list of elements with amount"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date(),
          end: new Date(),
          billable: true
        },
        {
          id: new Types.ObjectId(),
          description: 'Test2',
          start: new Date(),
          end: new Date(),
          billable: true
        }
      ];
      dataSource.setRecords(records);
      
      return controller.list().then(result => {  
        expect(result.length).toBe(records.length);
      })
    });

    it('should calculate billable amounts"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date('2024-01-10T10:00:00.000Z'),
          end: new Date('2024-01-10T11:00:00.000Z'),
          billable: true
        },
        {
          id: new Types.ObjectId(),
          description: 'Test2',
          start: new Date('2024-01-10T11:00:00.000Z'),
          end: new Date('2024-01-10T13:00:00.000Z'),
          billable: false
        },

        {
          id: new Types.ObjectId(),
          description: 'Test2',
          start: new Date('2024-01-13T10:00:00.000Z'),
          end: new Date('2024-01-14T10:00:00.000Z'),
          billable: true
        }
      ];
      dataSource.setRecords(records);
      const durationSpy = jest.spyOn(durationSrv, 'getDuration');
      const amountSpy = jest.spyOn(amountSrv, 'calcAmount');

      return controller.list().then(result => {
        expect(durationSpy).toHaveBeenCalledTimes(records.length);
        expect(amountSpy).toHaveBeenCalledTimes(2);
        for(let i = 0; i < records.length; i++) {
          expect(durationSpy).toHaveBeenNthCalledWith(i + 1, records[i].start, records[i].end);
        }
        expect(result[0].amount).toBeGreaterThan(0);
        expect(result[1].amount).toBe(0);
        expect(result[2].amount).toBeGreaterThan(0);
      })

    });
  });

  describe('detail', () => {
    it('should return a single record with amount"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date(),
          end: new Date(),
          billable: true
        },
        {
          id: new Types.ObjectId(),
          description: 'Test2',
          start: new Date(),
          end: new Date(),
          billable: true
        }
      ];
      dataSource.setRecords(records);
      
      return controller.detail(records[1].id.toString()).then(result => {
        expect(result.id).toBe(records[1].id);
        expect(result.amount).toBeDefined();
      })
    });

    it('should calculate billable amounts"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date('2024-01-10T10:00:00.000Z'),
          end: new Date('2024-01-10T11:00:00.000Z'),
          billable: true
        }
      ];
      dataSource.setRecords(records);
      const durationSpy = jest.spyOn(durationSrv, 'getDuration');
      const amountSpy = jest.spyOn(amountSrv, 'calcAmount');
      return controller.detail(records[0].id.toString()).then(result => {
        expect(durationSpy).toHaveBeenCalledWith(records[0].start, records[0].end);
        expect(amountSpy).toHaveBeenCalled();

        expect(result.amount).toBeGreaterThan(0);
      })
    });
    it('should leave non billable amounts to 0"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date('2024-01-10T10:00:00.000Z'),
          end: new Date('2024-01-10T11:00:00.000Z'),
          billable: false
        }
      ];
      dataSource.setRecords(records);
      const durationSpy = jest.spyOn(durationSrv, 'getDuration');
      const amountSpy = jest.spyOn(amountSrv, 'calcAmount');
      return controller.detail(records[0].id.toString()).then(result => {
        expect(durationSpy).toHaveBeenCalledWith(records[0].start, records[0].end);
        expect(amountSpy).not.toHaveBeenCalled();
        expect(result.amount).toBe(0);
      })
    });

    it('should throw an exception if not found"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date('2024-01-10T10:00:00.000Z'),
          end: new Date('2024-01-10T11:00:00.000Z'),
          billable: false
        }
      ];
      dataSource.setRecords(records);
      return expect(controller.detail('test')).rejects.toThrow('Not found');
    });

  });

  describe('create', () => {
    it('should add a new billable record', async () => {
      const record = {
        description: 'Test1',
        start: new Date('2024-01-10T10:00:00.000Z'),
        end: new Date('2024-01-10T11:00:00.000Z'),
        billable: true
      }
      return controller.create(record).then(result =>{
        expect(result.id).toBeDefined();
        expect(result.description).toBe(record.description);
        expect(result.billable).toBe(true);
        expect(result.amount).toBeGreaterThan(0);
      })
    });

    it('should add a new non billable record', async () => {
      const record = {
        description: 'Test1',
        start: new Date('2024-01-10T10:00:00.000Z'),
        end: new Date('2024-01-10T11:00:00.000Z'),
        billable: false
      }
      return controller.create(record).then(result =>{
        expect(result.id).toBeDefined();
        expect(result.description).toBe(record.description);
        expect(result.billable).toBe(false);
        expect(result.amount).toBe(0);
      })
    });
  })
});

Andiamo a fare un ultimo miglioramento prima di andare ad aggiungere complessità al nostro progetto. Voglio andare ad eliminare le ripetizioni del calcolo di duration e la composizione dell'oggetto risultato.

Andiamo per prima cosa a definire un DTO per i risultati (si i dto non sono solo per le richieste)

Ok, per il momento abbiamo fatto un po' di ordine ma non abbiamo ancora davvero sfruttato dei design pattern (almeno non consapevolmente). Andiamo ad aggiungere un po' di complessità al nostro progetto e vediamo perché quello che abbiamo fatto torna utile.

time-entry.dto.ts

...
export class TimeEntryResultDTO extends TimeEntry {
  amount: number;
}

Adesso andiamo a creare una classe che tornerà oggetti di questo tipo:
time-entry.result.factory.ts

import { TimeEntryDurationService } from "../duration/duration.service";
import { TimeEntryAmountService } from "../amount/amount.service";
import { TimeEntry } from "./time-entry.schema";
import { TimeEntryResultDTO } from "./time-entry.dto";
import { Injectable } from "@nestjs/common";

@Injectable()
export class TimeEntryResultFactory {

  getFactory(durationSrv: TimeEntryDurationService, amountSrv: TimeEntryAmountService) {
    return (timeEntry: TimeEntry): TimeEntryResultDTO => {
      const duration = durationSrv.getDuration(timeEntry.start, timeEntry.end);
      return {
        ...timeEntry,
        amount: timeEntry.billable ? amountSrv.calcAmount(duration) : 0,
      };
    }
  }
  
}

il controller diventa:
time-entry.controller.ts

import { TimeEntryDataSource } from './datasource/time-entry.ds.service';
import {
  Body,
  Controller,
  Get,
  HttpException,
  HttpStatus,
  Param,
  Post,
  UsePipes,
  ValidationPipe,
} from '@nestjs/common';
import { CalculatedTimeEntry } from './entities/time-entry.entity';
import { CreateTimeEntryDTO } from './entities/time-entry.dto';
import { TimeEntryResultFactory } from './entities/time-entry-result.factory';
import { TimeEntryDurationService } from './duration/duration.service';
import { TimeEntryAmountService } from './amount/amount.service';

@Controller('time-entries')
export class TimeEntryController {
  constructor(
    protected readonly dataSorce: TimeEntryDataSource,
    protected readonly durationSrv: TimeEntryDurationService,
    protected readonly amountSrv: TimeEntryAmountService,
    protected readonly resultFactoryProvider: TimeEntryResultFactory) {}

  @Get()
  async list(): Promise<CalculatedTimeEntry[]> {
    const list = await this.dataSorce.list();
    const resultFactory = this.resultFactoryProvider.getFactory(this.durationSrv, this.amountSrv);
    return list.map((e) => {
      return resultFactory(e);
    });
  }

  @Get(':id')
  async detail(@Param('id') id: string) {
    const record = await this.dataSorce.get(id);
    if (!record) {
      throw new HttpException('Not found', HttpStatus.NOT_FOUND);
    }
    const resultFactory = this.resultFactoryProvider.getFactory(this.durationSrv, this.amountSrv);
    return resultFactory(record);
  }

  @Post()
  @UsePipes(new ValidationPipe({transform: true}))
  async create(@Body() createTimeEntryDTO: CreateTimeEntryDTO) {
    const record = await this.dataSorce.add(createTimeEntryDTO);
    const resultFactory = this.resultFactoryProvider.getFactory(this.durationSrv, this.amountSrv);
    return resultFactory(record);
  }
}



time-entry-result.factory.spec.ts

import { TimeEntryAmountService } from "../amount/amount.service";
import { FixedAmountService } from "../amount/fixed-amount.service"
import { TimeEntryDurationService } from "../duration/duration.service";
import { ExactTimeEntryDurationService } from "../duration/exact-duration.service";
import { TimeEntryResultFactory } from "./time-entry-result.factory";
import { Types } from 'mongoose';

describe('TimEntryResultFactory', () => {
  let amountSrv: TimeEntryAmountService;
  let durationSrv: TimeEntryDurationService;
  beforeEach(() => {
    amountSrv = new FixedAmountService();
    durationSrv = new ExactTimeEntryDurationService();
  })
  it('should reuturn a billable result', () => {
    const entry = {
      id: new Types.ObjectId(),
      description: 'Test1',
      start: new Date('2024-01-10T10:00:00.000Z'),
      end: new Date('2024-01-10T11:00:00.000Z'),
      billable: true
    }
    const factory = new TimeEntryResultFactory();
    const amountSpy = jest.spyOn(amountSrv, 'calcAmount');
    const durationSpy = jest.spyOn(durationSrv, 'getDuration').mockReturnValue(60);
    const result = factory.getFactory(durationSrv, amountSrv)(entry);
    expect(durationSpy).toHaveBeenCalledWith(entry.start, entry.end);
    expect(amountSpy).toHaveBeenCalledWith(60)
    expect(result.id).toBe(entry.id);
    expect(result.amount).toBeDefined();
  });

  it('should reuturn a non billable result', () => {
    const entry = {
      id: new Types.ObjectId(),
      description: 'Test1',
      start: new Date('2024-01-10T10:00:00.000Z'),
      end: new Date('2024-01-10T11:00:00.000Z'),
      billable: false
    }
    const factory = new TimeEntryResultFactory();
    const amountSpy = jest.spyOn(amountSrv, 'calcAmount');
    const durationSpy = jest.spyOn(durationSrv, 'getDuration').mockReturnValue(60);
    const result = factory.getFactory(durationSrv, amountSrv)(entry);
    expect(durationSpy).toHaveBeenCalledWith(entry.start, entry.end);
    expect(amountSpy).not.toHaveBeenCalled();
    expect(result.id).toBe(entry.id);
    expect(result.amount).toBe(0);
  });
})

avendo spostato su questo test i controlli possiamo andare a rimuoverli dai test del controller.

time-entry.controller.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { TimeEntryController } from './time-entry.controller';
import { TimeEntryDataSource } from './datasource/time-entry.ds.service';
import { TimeEntryMockDataSource } from './mocks/time-entry.ds.mock.service';
import { Types } from 'mongoose';
import { TimeEntry } from './entities/time-entry.schema';
import { ExactTimeEntryDurationService } from './duration/exact-duration.service';
import { TimeEntryDurationService } from './duration/duration.service';
import { FixedAmountService } from './amount/fixed-amount.service';
import { TimeEntryAmountService } from './amount/amount.service';
import { TimeEntryResultFactory } from './entities/time-entry-result.factory';

describe('TimeEntryController', () => {
  let controller: TimeEntryController;
  let dataSource: TimeEntryMockDataSource;
  let spyFactory: jest.SpyInstance;
  let spyResult: jest.Mock;

  beforeEach(async () => {
    dataSource = new TimeEntryMockDataSource();
    const app: TestingModule = await Test.createTestingModule({
      controllers: [TimeEntryController],
      providers: [{
        provide: TimeEntryDataSource,
        useValue: dataSource
      },
      {provide: TimeEntryDurationService, useClass: ExactTimeEntryDurationService},
      {provide: TimeEntryAmountService, useClass: FixedAmountService},
      TimeEntryResultFactory
    ],
    }).compile();

    controller = app.get<TimeEntryController>(TimeEntryController);
    const resultFactory = app.get<TimeEntryResultFactory>(TimeEntryResultFactory);
    spyResult = jest.fn().mockResolvedValue({});
    spyFactory = jest.spyOn(resultFactory, 'getFactory');
    spyFactory.mockReturnValue(spyResult);
  });

  describe('list',  () => {
    it('should return a list of elements"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date(),
          end: new Date(),
          billable: true
        },
        {
          id: new Types.ObjectId(),
          description: 'Test2',
          start: new Date(),
          end: new Date(),
          billable: true
        }
      ];
      dataSource.setRecords(records);
      return controller.list().then(result => {
        expect(spyFactory).toHaveBeenCalled();
        for(let i = 0; i < records.length; i++) {
          expect(spyResult).toHaveBeenNthCalledWith(i+1, records[i]);
        }
        expect(result.length).toBe(records.length);
      })
    });
  });

  describe('detail', () => {
    it('should return a single record with amount"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date(),
          end: new Date(),
          billable: true
        },
        {
          id: new Types.ObjectId(),
          description: 'Test2',
          start: new Date(),
          end: new Date(),
          billable: true
        }
      ];
      dataSource.setRecords(records);
      return controller.detail(records[1].id.toString()).then(result => {
        expect(spyFactory).toHaveBeenCalled();
        expect(spyResult).toHaveBeenCalledWith(records[1]);
        expect(result).toStrictEqual({});
      })
    });

    it('should throw an exception if not found"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date('2024-01-10T10:00:00.000Z'),
          end: new Date('2024-01-10T11:00:00.000Z'),
          billable: false
        }
      ];
      dataSource.setRecords(records);
      return expect(controller.detail('test')).rejects.toThrow('Not found');
    });

  });

  describe('create', () => {
    it('should add a new record', async () => {
      const record = {
        description: 'Test1',
        start: new Date('2024-01-10T10:00:00.000Z'),
        end: new Date('2024-01-10T11:00:00.000Z'),
        billable: true
      }
      return controller.create(record).then(result =>{
        expect(spyFactory).toHaveBeenCalled();
        expect(spyResult).toHaveBeenCalled();
        expect(result).toStrictEqual({});
      })
    });
  })
});


Siamo arrivati a un buon punto, il controller contiene solo la logica che serve a tornare i dati e trasformarli secondo le specifiche, i test sono selettivi e verificano solo il comportamento specifico del loro componente, e il nostro codice è ben suddiviso.

Andiamo a vedere cosa abbiamo usato finora e un po' di teoria: