Bassem 17/08
In questo post condivido con voi come ho implementato i concetti di CI / CD nel mio blog personale costruito con la tecnica JAMstack utilizzando Scully (generatore di siti statici per Angular), Github e Netlify.
Quando parliamo di CI, intendiamo integrazione continua; il concetto consiste nell'eseguire una suite di test con ogni push sulla repo per testare il codice scritto. L'obiettivo principale è mantenere la nostra applicazione priva di bug.
D'altra parte CD sta per distribuzione continua, il processo consiste nell'automatizzare la distribuzione dopo che i test di integrazione sono stati eseguiti correttamente. Il vantaggio è portare rapidamente modifiche al codice e nuove funzionalità in produzione ed avere un feedback degli utenti.
Integrazione continua
Questa fase può essere suddivisa in passaggi, il primo è produrre i casi di test, eventualmente per ogni riga di codice. Quando si usa la metodologia TDD (test driven development), è necessario scrivere il test prima di qualsiasi riga di codice (molto utile per ridurre i bug e avere una visione chiara della funzionalità, che si vuole implementare prima di scrivere il codice).
In Angular, ogni volta che si crea un componente o un servizio utilizzando la CLI; viene generato automaticamente un test con il nome component.spec.ts. La CLI si occupa della configurazione di Jasmine e Karma per noi.
Vediamo il test per il seguente componente:
export class DashboardComponent implements OnDestroy {
keyword: string;
subFilter: Subscription;
linksFiltred$: Observable<any>;
constructor(private scully: ScullyRoutesService, private route: ActivatedRoute) {
this.subFilter = this.route.params.subscribe(params => {
this.keyword = params['categoryId'];
this.linksFiltred$ = this.scully.available$;
});
}
ngOnDestroy(): void {
this.subFilter.unsubscribe();
}
currentTag(link: any): boolean {
…
}
Come potete vedere è un componente semplice, ma con alcune dipendenze che devono essere "moccate" nello unit test. Nel costruttore ho iniettato ActivatedRoute per ottenere i parametri associati alla rotta corrente e ScullyRoutesService per avere accesso alle rotte disponibili generate da Scully, in particolare quelle relative ai file markdown.
Ecco il codice per testare il DashboardComponent:
describe('DashboardComponent', () => {
let component: DashboardComponent;
let fixture: ComponentFixture<DashboardComponent>;
beforeEach(() => {
const link = {
keywords:'angular',
date: '2020-04-26'
}
const params = {
categoryId: 'angular'
}
TestBed.configureTestingModule({
declarations: [DashboardComponent],
providers: [
{ provide: ScullyRoutesService, useValue: {
available$: of([link])
} },
{ provide: ActivatedRoute, useValue: {
params: of(params)
}}
]
});
fixture = TestBed.createComponent(DashboardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy;
});
it('should inject props', () => {
expect(component.keyword).toEqual('angular');
});
it('should have <li> with "Angular"', () => {
const bannerElement: HTMLElement = fixture.nativeElement;
const p = bannerElement.querySelector('li');
expect(p.textContent).toContain('Angular');
});
});
La maggior parte del codice riportato sopra è generato dalla CLI di Angular, analizziamolo:
BeforeEach / TestBed:
Il BeforeEach()
è stato usato per evitare duplicazione di codice per la configurazione TestBed. Questa è la parte più importante, perché configuriamo un'istanza del componente che viene testato.
Come avete visto nel codice del componente, ci sono due servizi iniettati, che al momento devono essere "moccati" o creati come "stub" per isolare il nostro componente. Come potete vedere nella seguente parte di codice, ho creato due "stub" (La differenza principale tra mock e stub è che il mock può essere configurato durante l'esecuzione del test, mentre gli stub sono già configurati con valori predeterminati.) :
TestBed.configureTestingModule({
declarations: [DashboardComponent],
providers: [
{ provide: ScullyRoutesService, useValue: {
available$: of([link])
} },
{ provide: ActivatedRoute, useValue: {
params: of(params)
}}
]
});
Per la ScullyRoutesService ho configurato la proprietà "available$" con un observable dell'oggetto "link" utilizzando l'operatore "of" di rxjs, fatto lo stesso con i parametri della proprietà di ActivatedRoute. Una volta definita la configurazione di testBed, creiamo un'istanza con la ComponentFixture.
Ora nel seguente test, ci assicuriamo che il nostro componente sia istanziato:
it('should create', () => {
expect(component).toBeTruthy;
});
Un secondo test, è il seguente:
it('should inject props', () => {
expect(component.keyword).toEqual('angular');
});
Sto solo assicurando che i miei "stub" vengano iniettati. Quando si testa l'HTML generato, devi assicurarti di aver chiamato detectChanges prima delle asserzioni (come abbiamo fatto nel blocco BeforeEach) :
fixture.detectChanges()
Per avere un test affidabile, dovremmo impostare una percentuale di copertura, più è alta, meglio è, una buona media sarebbe dell'80%. Per abilitare la copertura del codice e la percentuale desiderata, date un'occhiata alla documentazione ufficiale.
Una volta che i test sono pronti e completati, dobbiamo eseguirli contro ogni richiesta di merge o push, si spera prima localmente (eseguire sempre i test prima di fare il push 😀 ) e prima di approvare la richiesta.
GitHup actions
L'ultimo passaggio nell'integrazione continua è l'esecuzione automatica della suite di test sviluppata, per questo ho creato un'azione Githup personalizzata. Ecco il mio deployment.yml (deve essere posizionato sotto github/workflows), che sto utilizzando, si basa su un'azione standard node.js:
name: Node.js CI
on:
push:
branches: [ dev, master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [12.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Cache node modules
uses: actions/cache@v1
env:
cache-name: cache-node-modules
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Install Dependencies
run: sudo npm install
- name: Test
run: npm run test -- --no-watch --no-progress --browsers=ChromeHeadlessCI
- name: build app angular
run: |
npm run build -- --prod --stats-json
- name: build static scully
run: npm run scully -- --scanRoutes --showGuessError
Analizziamolo, partendo dalla configurazione dell'ambiente:
on:
push:
branches: [ dev, master ]
pull_request:
branches: [ master ]
Qui l'azione viene attivata ad ogni push su dev e master e ad ogni pull request sul master (il trigger può essere qualsiasi GitHub event ).
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [12.x]
Nel codice sopra decido quale versione del sistema operativo e del node.js da utilizzare nel flusso di lavoro. Vale la pena ricordare che GithupActions supporta anche contenitori Docker personalizzati. E infine ecco i passaggi del flusso di lavoro:
run: npm run test -- --no-watch --no-progress --browsers=ChromeHeadlessCI
Stiamo utilizzando npm perché la @angular/cli non è installata, non abbiamo bisogno del report, quindi lo eseguiamo senza il flag di avanzamento e con un browser chrome headless. Quest'ultimo passaggio deve essere configurato nel file di configurazione Karma, karma.conf.js:
browsers: ['Chrome'],
customLaunchers: {
ChromeHeadlessCI: {
base: 'ChromeHeadless',
flags: ['--no-sandbox']
}
Deployment continui
Il mio blog è su Netlify. È davvero facile automatizzare le distribuzioni con questa piattaforma. Una volta che il repository Github è connesso alla piattaforma con accessi di lettura e scrittura (necessarie per vedere controlli, stati di commit e richieste pull) e la configurazione di "deployment" è stata eseguita correttamente, il sito verrà "buildato" automaticamente con ogni commit. Dopo l'autorizzazione, Netlify viene elencato nella scheda di integrazione del repository su Github.
Deployment settings
Ci sono pochi parametri da impostare:
npm run build -- --prod --stats-json && npm run scully -- --scanRoutes