DEV Community

Rubén Aguilera Díaz-Heredero
Rubén Aguilera Díaz-Heredero

Posted on

Como construir una SPA sin frameworks y con testing

A día de hoy estamos muy acostumbrados a que cuando queremos hacer un proyecto SPA tenemos tres opciones: Vue, Angular o React; pero se nos olvida que podemos hacerlo sin ninguno de estos frameoworks que nos obliga a estudiarlos y trabajar de la forma que nos marcan para conseguir nuestros objetivos de negocio.

Creación del proyecto desde cero

Para crear el proyecto de nuestra SPA desde cero podemos hacerlo de forma más manual con Webpack como se describe en este artículo e integrar Jest: https://dev.to/raguilera82/montar-spa-de-cero-con-vanilla-y-jest-3odb

Pero a día de hoy tenemos otra opción para crear nuestro proyecto, con todos los procesos necesarios, llamada Vite que además se integra con una solución de testing llamada Vitest, muy similar a Jest.

Para ello solo tenemos que ejecutar:

$> npm create vite@latest spa-app -- --template vanilla 
$> cd spa-app
$> npm install
Enter fullscreen mode Exit fullscreen mode

Y para añadir Vitest solo tenemos que ejecutar:

$> npm install -D vitest
Enter fullscreen mode Exit fullscreen mode

De esta forma quedaría el package.json de nuestro proyecto en este estado inicial:

{
  "name": "spa-app",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "test": "vitest --coverage"
  },
  "devDependencies": {
    "vite": "^4.3.2",
    "vitest": "^0.31.1"
  }
}
Enter fullscreen mode Exit fullscreen mode

Únicamente tenemos las dependencias de desarrollo de vite y vitest. Ahora para arrancar el proyecto que viene de ejemplo podemos hacerlo con el comando:

$> npm run dev
Enter fullscreen mode Exit fullscreen mode

Y la URL http://localhost:5173 podemos ver el resultado.

Si queremos crear la carpeta dist con los ficheros estáticos de nuestra aplicación, solo tenemos que ejecutar:

$> npm run build
Enter fullscreen mode Exit fullscreen mode

Y para probarlo en modo producción, ejecutamos el comando:

$> npm run preview
Enter fullscreen mode Exit fullscreen mode

Organización de proyecto

Independientemente de la forma de crear nuestro proyecto y de la funcionalidad que le queramos dar, vamos a incluir una carpeta src donde vamos a tener el código fuente de nuestro proyecto, organizado en una serie de carpetas cada una con una única responsabilidad (a medida que el proyecto crezca se puede llevar el mismo concepto a nivel de módulos funcionales):

  • components: va a almacenar "web components" que llaman a casos de uso. Es decir, componentes que conocen de nuestra lógica y que es posible que tengan relación con el estado de nuestra aplicación.
  • infra: va a almacenar los ficheros relacionados con tecnologías en las que nos apoyamos para realizar la aplicación. Por ejemplo, estarán en esta carpeta todo lo relacionado con el router de la spa, la gestión de estado, el fichero de conexión con Firebase si lo usamos, etc...
  • model: va a almacenar las clases de dominio de nuestra aplicación y que serán utilizadas por los elementos de la carpeta "components" y que como recomendación no se corresponderán con lo que venga en el API de turno.
  • pages: va a almacenar "web components" que estén relacionados con el router de nuestra SPA. Es decir, van a ser nuestras páginas a las que podemos ir navegando desde la URL a través del router.
  • repositories: va a almacenar las clases que permiten el acceso al API de turno para interacturar con un servidor. Es importante, que las clases de este nivel no tengan nada de lógica de negocio, más allá de conectarse con el api y transmitir la información al resto de la aplicación.
  • services: contiene métodos síncronos que resuelven funcionalidad transversal a distintos casos de uso.
  • ui: contiene los "web components" que solo utilizamos para la interfaz de nuestra aplicación, pero sin ningún tipo de lógica. Es decir, no llaman a casos de uso, reciben la información a través de propiedades o eventos de otros componentes y se comunican con el resto a través de eventos. Decimos que son componentes "tontos".
  • usecases: contiene las acciones que permitimos realizar a los personas que usan nuestra aplicación. Es importante que cada caso de uso tenga un único método llamado run o execute que reciba la información que necesita para ejecutarse y devuelva la información solicitada, todo de forma síncrona para favorecer el testing.

Implementación de un caso uso

Imaginad que nos piden hacer una página en nuestra aplicación que conectándose a un API (https://jsonplaceholder.typicode.com/) muestre un listado de posts, donde queremos mostrar: postId, heading y content.

Por lo que ya podemos crear el fichero "src/model/post.js" con el siguiente contenido:

export class Post {
    constructor({postId, heading, content}) {
        this.postId = postId;
        this.heading = heading;
        this.content = content;
    }
}
Enter fullscreen mode Exit fullscreen mode

Vamos a empezar por definir el test de nuestro caso de uso, lo que nos va a permitir abstraernos de la parte visual y del API con el que conectar, por lo menos de momento. Entonces creamos la carpeta "test" y dentro el fichero "all-posts.usecase.spec.js" empezamos a pensar que información necesita y que información tiene que devolver este caso de uso, sabiendo que el caso de uso solo va a tener un método estático "execute".

También sabemos que tenemos que recuperar la información llamando a la URL https://jsonplaceholder.typicode.com/posts la cual ejecutada en Postman o en el propio navegador, nos informa que van a llegar 100 registros con la estructura: userId, id, title, body. Por lo que el resultado de nuestro caso de uso tendrá que devolver esos 100 registros y, al menos, comprobar que la primera posición coincide en valores con la primera posición de la información que devuelve el servidor.

De esta forma, creamos una carpeta "fixtures" dentro de "test" y creamos un fichero posts.json con el contenido de lo que devuelve el servidor.

Quedando algo como esto:

import { describe, it } from 'vitest';
import postsServer from './fixtures/posts.json';

describe('All posts use case', () => {
    it('should get all posts', () => {
        const posts = AllPostsUseCase.execute();
        expect(posts.length).toBe(postsServer.length);
        expect(posts[0].postId).toBe(postsServer[0].id);
        expect(posts[0].heading).toBe(postsServer[0].title);
        expect(posts[0].content).toBe(postsServer[0].body);
    })
})
Enter fullscreen mode Exit fullscreen mode

Obviamente, el test no pasa porque no tenemos implementado el caso de uso, pero ya tenemos un test que responde a las necesidades de nuestra lógica de negocio.

Ahora creamos el fichero "src/usecases/all-posts.usecase.js" el cual va a tener un único método "execute" que inicialmente va a devolver nuestro fixture transformado al modelo que espera la aplicación:

import { Post } from '../model/post';
import postsServer from './../../test/fixtures/posts.json';

export class AllPostsUseCase {
    static execute() {
        return postsServer.map((postServer) => {
            return new Post({
                content: postServer.body,
                heading: postServer.title,
                postId: postServer.id
            })
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Ahora ejecutamos el test con el comando npm run test y conseguimos que el test pase. Obviamente no estamos conectando con el servidor, pero ya hemos probado que la transformación a nuestro modelo simulando la llamada es correcta.

El siguiente paso es conectar con el servidor, para ello vamos a crear el fichero "src/repositories/posts.repository.js" donde vamos a implementar la llamada al servidor con la infraestructura que más nos guste, algunas personas lo querrán hacer con fetch, aquí lo vamos a ver con axios porque es más compatible con el testing.

Por lo que lo primero es instalar la dependencia.

$> npm install --save axios
Enter fullscreen mode Exit fullscreen mode

Y el contenido del fichero quedaría de la siguiente forma:

import axios from "axios";

export class PostsRepository {
    async getAllPosts() {
        return (await axios.get("https://jsonplaceholder.typicode.com/posts")).data;
    }
}
Enter fullscreen mode Exit fullscreen mode

Ahora nuestro caso de uso ya no necesita el fixture y debe ser asíncrono:

import { Post } from '../model/post';
import { PostsRepository } from '../repositories/posts.repository';

export class AllPostsUseCase {
    static async execute() {
        const repository = new PostsRepository();
        const postsServer = await repository.getAllPosts();
        return postsServer.map((postServer) => {
            return new Post({
                content: postServer.body,
                heading: postServer.title,
                postId: postServer.id
            })
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

y nuestro test ahora también es asíncrono:

import { describe, expect, it } from 'vitest';
import postsServer from './fixtures/posts.json';
import { AllPostsUseCase } from '../src/usecases/all-posts.usecase';

describe('All posts use case', () => {
    it('should get all posts', async () => {
        const posts = await AllPostsUseCase.execute();
        expect(posts.length).toBe(postsServer.length);
        expect(posts[0].postId).toBe(postsServer[0].id);
        expect(posts[0].heading).toBe(postsServer[0].title);
        expect(posts[0].content).toBe(postsServer[0].body);
    })
})
Enter fullscreen mode Exit fullscreen mode

Si volvemos a ejecutar el comando npm run test veremos que nuestro test sigue pasando en verde, pero... por cada vez que lanzamos el test estamos haciendo la llamada real al servidor y esto, en la mayoría de los casos, no es lo más recomendable. Es por ello que tenemos que mockear la llamada y para ello podemos usar vitest prácticamente de forma igual a como haríamos con jest, una forma podría ser la siguiente:

import { beforeEach, describe, expect, it, vi } from 'vitest';
import postsServer from './fixtures/posts.json';
import { AllPostsUseCase } from '../src/usecases/all-posts.usecase';
import { PostsRepository } from '../src/repositories/posts.repository';

vi.mock('../src/repositories/posts.repository');

describe('All posts use case', () => {

    beforeEach(() => {
        PostsRepository.mockClear();
    })

    it('should get all posts', async () => {

        PostsRepository.mockImplementation(() => {
            return {
                getAllPosts: () => {
                    return postsServer;
                }
            }
        })

        const posts = await AllPostsUseCase.execute();
        expect(posts.length).toBe(postsServer.length);
        expect(posts[0].postId).toBe(postsServer[0].id);
        expect(posts[0].heading).toBe(postsServer[0].title);
        expect(posts[0].content).toBe(postsServer[0].body);
    })
})
Enter fullscreen mode Exit fullscreen mode

En este momento estamos haciendo un mock del repositorio y le damos la respuesta que daría el servidor al llamar al método "getAllPosts". Es importante que antes de ejecutar el tests nos aseguremos resetear el mock, esto lo hacemos en el beforeEach.

De esta forma la parte funcional del caso de uso queda resuelta a la espera de ser invocada desde la página correspondiente y no hemos tenido que levantar la aplicación para nada, lo que hace que el desarrollo de la funcionalidad sea mucho más ágil.

Creación de la página

Para crear una página en un aplicación spa necesitamos tener un router que se encargue de simular la navegación entre páginas de nuestra aplicación.

Los frameworks (Angular, React y Vue) ya nos ofrecen su solución propia de routing pero nos casa a esta tecnología, a fin de mantenernos Vanilla o Frameworksless vamos a recurrir a una librería mucho más liviana que nos soluciona el problema como es vaadin-router (https://github.com/vaadin/router), la cual instalamos con el siguiente comando:

$> npm install --save @vaadin/router
Enter fullscreen mode Exit fullscreen mode

Para utilizar el router vamos a crear el fichero "src/infra/router.js" con el siguiente contenido:

import { Router } from "@vaadin/router";
import './pages/posts.page';

const outlet = document.querySelector("#app");
export const router = new Router(outlet);

router.setRoutes([
  { path: "/", component: "posts-page" },
  { path: "(.*)", redirect: "/" },
]);
Enter fullscreen mode Exit fullscreen mode

No hay que olvidar importar este fichero dentro del fichero main.js de nuestra aplicación, dejándolo de esta forma:

import "./src/infra/router";
Enter fullscreen mode Exit fullscreen mode

Y asegurarse de que existe el elemento con identificador "app" dentro del fichero index.html, en nuestro caso:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/main.js"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Ahora vamos a crear la página que tenemos importada en el fichero . Para ello creamos el fichero "src/pages/posts.page.js" el cual va a ser un web component nativo que usaremos como contenedor de otros componentes. El código podría ser el siguiente simplemente para mostrar un título en la página y el conjunto de posts que vamos a recuperar del servidor a través del caso de uso con radh-posts:

import "./../components/posts.component";

export class PostsPage extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `
            <h1>Posts Page</h1>
            <radh-posts></k-posts>
        `;
  }
}

customElements.define("posts-page", PostsPage);
Enter fullscreen mode Exit fullscreen mode

Entonces vamos a implementar el componente "radh-posts" que será el que tenga la inteligencia de saber a qué caso de uso llamar, y que por tanto lo vamos a crear en la ruta "src/components/posts-component.js" para su implementación nos vamos a apoyar en la librería lit que es una capa muy fina por encima del estándar HTMLElement que nos simplifica en parte el trabajo, para instalarla tenemos que ejecutar:

$> npm install --save lit
Enter fullscreen mode Exit fullscreen mode

Una posible implementación de este componente sería:

Nota: el método createRenderRoot lo usamos para que todo el contenido del componente sea directamente accesible por los lectores de pantalla y las arañas del SEO.

import { LitElement, html } from "lit";
import { AllPostsUseCase } from "../usecases/all-posts.usecase";
import "./../ui/post.ui";

export class PostsComponent extends LitElement {
  createRenderRoot() {
    return this;
  }

  static properties = {
    posts: {
      type: Array,
    },
  };

  async connectedCallback() {
    super.connectedCallback();
    this.posts = [];
    this.posts = await AllPostsUseCase.execute();
    console.log(this.posts);
  }

  render() {
    return html`${this.posts.map(
      (post) => html`<post-ui .post="${post}"></post-ui>`
    )}`;
  }
}

customElements.define("k-posts", PostsComponent);
Enter fullscreen mode Exit fullscreen mode

Como se puede apreciar este componente se apoya en otro de UI para mostrar la información, por lo que creamos el fichero "src/ui/post.ui.js" con esta posible implementación:

import { LitElement, html } from "lit";

export class PostUI extends LitElement {
  createRenderRoot() {
    return this;
  }

  static properties = {
    post: {
      type: Object,
    },
  };

  render() {
    return html`
      <div>
        <p>Post Id: ${this.post.postId}</p>
        <p>Heading: ${this.post.heading}</p>
        <p>Content: ${this.post.content}</p>
      </div>
    `;
  }
}

customElements.define("post-ui", PostUI);
Enter fullscreen mode Exit fullscreen mode

De esta forma si hemos realizado correctamente las distintas importaciones al arrancar la aplicación con npm run dev deberemos ver algo como esto por pantalla:

Resultado en el navegador

Conclusiones

Espero que este tutorial refleje la poca necesidad que tenemos ahora mismo del uso de frameworks para conseguir nuestros objetivos con una spa.

De todos modos, esta implementación no es incompatible con los frameworks que deberían cubrir los aspectos de routing y creación de componentes visuales dejando tal cual la implementación y testing de los casos de uso.

Top comments (0)