Trabalhando com TypeScript, injeção de dependência e bots de discórdia

Publicados: 2022-03-11

Tipos e código testável são duas das maneiras mais eficazes de evitar bugs, especialmente quando o código muda ao longo do tempo. Podemos aplicar essas duas técnicas ao desenvolvimento JavaScript aproveitando o TypeScript e o padrão de design de injeção de dependência (DI), respectivamente.

Neste tutorial do TypeScript, não abordaremos o básico do TypeScript diretamente, exceto a compilação. Em vez disso, simplesmente demonstraremos as práticas recomendadas do TypeScript enquanto explicamos como criar um bot Discord do zero, conectar testes e DI e criar um serviço de amostra. Estaremos usando:

  • Node.js
  • TypeScript
  • Discord.js, um wrapper para a API Discord
  • InversifyJS, uma estrutura de injeção de dependência
  • Bibliotecas de teste: Mocha, Chai e ts-mockito
  • Bônus: Mongoose e MongoDB, para escrever um teste de integração

Como configurar seu projeto Node.js

Primeiro, vamos criar um novo diretório chamado typescript-bot . Em seguida, insira-o e crie um novo projeto Node.js executando:

 npm init

Nota: Você também pode usar yarn para isso, mas vamos nos ater ao npm para ser mais breve.

Isso abrirá um assistente interativo, que configurará o arquivo package.json . Você pode pressionar Enter com segurança para todas as perguntas (ou fornecer algumas informações, se desejar). Então, vamos instalar nossas dependências e dependências de desenvolvimento (aquelas que são necessárias apenas para os testes).

 npm i --save typescript discord.js inversify dotenv @types/node reflect-metadata npm i --save-dev chai mocha ts-mockito ts-node @types/chai @types/mocha

Em seguida, substitua a seção "scripts" gerada em package.json por:

 "scripts": { "start": "node src/index.js", "watch": "tsc -p tsconfig.json -w", "test": "mocha -r ts-node/register \"tests/**/*.spec.ts\"" },

As aspas duplas em tests/**/*.spec.ts são necessárias para encontrar arquivos recursivamente. (Observação: a sintaxe pode variar para desenvolvedores que usam Windows.)

O script start será usado para iniciar o bot, o script watch para compilar o código TypeScript e test para executar os testes.

Agora, nosso arquivo package.json deve ficar assim:

 { "name": "typescript-bot", "version": "1.0.0", "description": "", "main": "index.js", "dependencies": { "@types/node": "^11.9.4", "discord.js": "^11.4.2", "dotenv": "^6.2.0", "inversify": "^5.0.1", "reflect-metadata": "^0.1.13", "typescript": "^3.3.3" }, "devDependencies": { "@types/chai": "^4.1.7", "@types/mocha": "^5.2.6", "chai": "^4.2.0", "mocha": "^5.2.0", "ts-mockito": "^2.3.1", "ts-node": "^8.0.3" }, "scripts": { "start": "node src/index.js", "watch": "tsc -p tsconfig.json -w", "test": "mocha -r ts-node/register \"tests/**/*.spec.ts\"" }, "author": "", "license": "ISC" }

Criando um novo aplicativo no painel de aplicativos do Discord

Para interagir com a API Discord, precisamos de um token. Para gerar esse token, precisamos registrar um aplicativo no painel do desenvolvedor do Discord. Para fazer isso, você precisa criar uma conta no Discord e acessar https://discordapp.com/developers/applications/. Em seguida, clique no botão Novo Aplicativo :

Botão "Novo aplicativo" do Discord.

Escolha um nome e clique em Criar . Em seguida, clique em BotAdicionar Bot e pronto. Vamos adicionar o bot a um servidor. Mas não feche esta página ainda, precisaremos copiar um token em breve.

Adicione seu bot do Discord ao seu servidor

Para testar nosso bot, precisamos de um servidor Discord. Você pode usar um servidor existente ou criar um novo. Para fazer isso, copie o CLIENT_ID do bot — encontrado na guia General Information — e use-o como parte desta URL de autorização especial:

https://discordapp.com/oauth2/authorize?client_id=<CLIENT_ID>&scope=bot

Quando você acessa essa URL em um navegador, aparece um formulário onde você pode escolher o servidor onde o bot deve ser adicionado.

Mensagem de boas-vindas padrão do Discord em resposta à entrada do nosso bot no servidor.

Depois de adicionar o bot ao seu servidor, você deverá ver uma mensagem como a acima.

Criando o arquivo .env

Precisamos de alguma maneira de salvar o token em nosso aplicativo. Para fazer isso, vamos usar o pacote dotenv . Primeiro, obtenha o token do Discord Application Dashboard ( BotClick to Reveal Token ):

O link "Clique para revelar o token" na seção Bot do Discord.

Agora, crie um arquivo .env , copie e cole o token aqui:

 TOKEN=paste.the.token.here

Se você usa Git, esse arquivo deve ser colocado em .gitignore , para que o token não seja comprometido. Além disso, crie um arquivo .env.example , para que se saiba que TOKEN precisa ser definido:

 TOKEN=

Compilando TypeScript

Para compilar o TypeScript, você pode usar o comando npm run watch . Alternativamente, se você usa PHPStorm (ou outro IDE), basta usar o observador de arquivos do plug-in TypeScript e deixar seu IDE lidar com a compilação. Vamos testar nossa configuração criando um arquivo src/index.ts com o conteúdo:

 console.log('Hello')

Além disso, vamos criar um arquivo tsconfig.json como abaixo. O InversifyJS requer experimentalDecorators , emitDecoratorMetadata , es6 e reflect-metadata :

 { "compilerOptions": { "module": "commonjs", "moduleResolution": "node", "target": "es2016", "lib": [ "es6", "dom" ], "sourceMap": true, "types": [ // add node as an option "node", "reflect-metadata" ], "typeRoots": [ // add path to @types "node_modules/@types" ], "experimentalDecorators": true, "emitDecoratorMetadata": true, "resolveJsonModule": true }, "exclude": [ "node_modules" ] }

Se o observador de arquivos funcionar corretamente, ele deve gerar um arquivo src/index.js e executar npm start deve resultar em:

 > node src/index.js Hello

Criando uma classe de bot

Agora, vamos finalmente começar a usar o recurso mais útil do TypeScript: os tipos. Vá em frente e crie o seguinte arquivo src/bot.ts :

 import {Client, Message} from "discord.js"; export class Bot { public listen(): Promise<string> { let client = new Client(); client.on('message', (message: Message) => {}); return client.login('token should be here'); } }

Agora, podemos ver o que precisamos aqui: um token! Vamos apenas copiar e colar aqui, ou carregar o valor direto do ambiente?

Nem. Em vez disso, vamos escrever um código mais sustentável, extensível e testável injetando o token usando nossa estrutura de injeção de dependência de escolha, InversifyJS.

Além disso, podemos ver que a dependência do Client é codificada. Nós vamos injetar isso também.

Configurando o contêiner de injeção de dependência

Um contêiner de injeção de dependência é um objeto que sabe instanciar outros objetos. Normalmente, definimos dependências para cada classe e o contêiner DI cuida de resolvê-las.

O InversifyJS recomenda colocar dependências em um arquivo inversify.config.ts , então vamos em frente e adicionar nosso contêiner DI lá:

 import "reflect-metadata"; import {Container} from "inversify"; import {TYPES} from "./types"; import {Bot} from "./bot"; import {Client} from "discord.js"; let container = new Container(); container.bind<Bot>(TYPES.Bot).to(Bot).inSingletonScope(); container.bind<Client>(TYPES.Client).toConstantValue(new Client()); container.bind<string>(TYPES.Token).toConstantValue(process.env.TOKEN); export default container;

Além disso, os documentos do InversifyJS recomendam criar um arquivo types.ts e listar cada tipo que vamos usar, juntamente com um Symbol relacionado. Isso é bastante inconveniente, mas garante que não haja colisões de nomenclatura à medida que nosso aplicativo cresce. Cada Symbol é um identificador único, mesmo quando seu parâmetro de descrição é o mesmo (o parâmetro é apenas para fins de depuração).

 export const TYPES = { Bot: Symbol("Bot"), Client: Symbol("Client"), Token: Symbol("Token"), };

Sem usar o Symbol s, veja como fica quando ocorre uma colisão de nomenclatura:

 Error: Ambiguous match found for serviceIdentifier: MessageResponder Registered bindings: MessageResponder MessageResponder

Neste ponto, é ainda mais inconveniente decidir qual MessageResponder deve ser usado, especialmente se nosso contêiner de DI ficar grande. O uso de Symbol s cuida disso, e não criamos literais de string estranhos no caso de ter duas classes com o mesmo nome.

Usando o contêiner no aplicativo Discord Bot

Agora, vamos modificar nossa classe Bot para usar o contêiner. Precisamos adicionar @injectable e @inject() para fazer isso. Aqui está a nova classe Bot :

 import {Client, Message} from "discord.js"; import {inject, injectable} from "inversify"; import {TYPES} from "./types"; import {MessageResponder} from "./services/message-responder"; @injectable() export class Bot { private client: Client; private readonly token: string; constructor( @inject(TYPES.Client) client: Client, @inject(TYPES.Token) token: string ) { this.client = client; this.token = token; } public listen(): Promise < string > { this.client.on('message', (message: Message) => { console.log("Message received! Contents: ", message.content); }); return this.client.login(this.token); } }

Finalmente, vamos instanciar nosso bot no arquivo index.ts :

 require('dotenv').config(); // Recommended way of loading dotenv import container from "./inversify.config"; import {TYPES} from "./types"; import {Bot} from "./bot"; let bot = container.get<Bot>(TYPES.Bot); bot.listen().then(() => { console.log('Logged in!') }).catch((error) => { console.log('Oh no! ', error) });

Agora, inicie o bot e adicione-o ao seu servidor. Então, se você digitar uma mensagem no canal do servidor, ela deve aparecer nos logs na linha de comando assim:

 > node src/index.js Logged in! Message received! Contents: Test

Por fim, temos as bases configuradas: tipos TypeScript e um contêiner de injeção de dependência dentro do nosso bot.

Implementando a lógica de negócios

Vamos direto ao cerne do assunto deste artigo: criar uma base de código testável. Em suma, nosso código deve implementar as melhores práticas (como SOLID), não ocultar dependências, não usar métodos estáticos.

Além disso, não deve apresentar efeitos colaterais quando executado e ser facilmente zombado.

Para simplificar, nosso bot fará apenas uma coisa: ele pesquisará as mensagens recebidas e, se uma delas contiver a palavra “ping”, usaremos um dos comandos disponíveis do bot Discord para que o bot responda com “pong! ” a esse usuário.

Para mostrar como injetar objetos personalizados no objeto Bot e testá-los, criaremos duas classes: PingFinder e MessageResponder . Vamos injetar MessageResponder na classe Bot e PingFinder em MessageResponder .

Aqui está o arquivo src/services/ping-finder.ts :

 import {injectable} from "inversify"; @injectable() export class PingFinder { private regexp = 'ping'; public isPing(stringToSearch: string): boolean { return stringToSearch.search(this.regexp) >= 0; } }

Em seguida, injetamos essa classe no arquivo src/services/message-responder.ts :

 import {Message} from "discord.js"; import {PingFinder} from "./ping-finder"; import {inject, injectable} from "inversify"; import {TYPES} from "../types"; @injectable() export class MessageResponder { private pingFinder: PingFinder; constructor( @inject(TYPES.PingFinder) pingFinder: PingFinder ) { this.pingFinder = pingFinder; } handle(message: Message): Promise<Message | Message[]> { if (this.pingFinder.isPing(message.content)) { return message.reply('pong!'); } return Promise.reject(); } }

Por fim, aqui está uma classe Bot modificada, que usa a classe MessageResponder :

 import {Client, Message} from "discord.js"; import {inject, injectable} from "inversify"; import {TYPES} from "./types"; import {MessageResponder} from "./services/message-responder"; @injectable() export class Bot { private client: Client; private readonly token: string; private messageResponder: MessageResponder; constructor( @inject(TYPES.Client) client: Client, @inject(TYPES.Token) token: string, @inject(TYPES.MessageResponder) messageResponder: MessageResponder) { this.client = client; this.token = token; this.messageResponder = messageResponder; } public listen(): Promise<string> { this.client.on('message', (message: Message) => { if (message.author.bot) { console.log('Ignoring bot message!') return; } console.log("Message received! Contents: ", message.content); this.messageResponder.handle(message).then(() => { console.log("Response sent!"); }).catch(() => { console.log("Response not sent.") }) }); return this.client.login(this.token); } }

Nesse estado, o aplicativo não será executado porque não há definições para as classes MessageResponder e PingFinder . Vamos adicionar o seguinte ao arquivo inversify.config.ts :

 container.bind<MessageResponder>(TYPES.MessageResponder).to(MessageResponder).inSingletonScope(); container.bind<PingFinder>(TYPES.PingFinder).to(PingFinder).inSingletonScope();

Além disso, vamos adicionar símbolos de tipo a types.ts :

 MessageResponder: Symbol("MessageResponder"), PingFinder: Symbol("PingFinder"),

Agora, após reiniciar nosso aplicativo, o bot deve responder a todas as mensagens que contenham “ping”:

O bot responde a uma mensagem contendo a palavra "ping".

E aqui está como fica nos logs:

 > node src/index.js Logged in! Message received! Contents: some message Response not sent. Message received! Contents: message with ping Ignoring bot message! Response sent!

Criando testes de unidade

Agora que temos as dependências devidamente injetadas, é fácil escrever testes de unidade. Vamos usar Chai e ts-mockito para isso; no entanto, existem muitos outros executores de teste e bibliotecas de simulação que você pode usar.

A sintaxe de zombaria no ts-mockito é bastante detalhada, mas também fácil de entender. Veja como configurar o serviço MessageResponder e injetar o mock do PingFinder nele:

 let mockedPingFinderClass = mock(PingFinder); let mockedPingFinderInstance = instance(mockedPingFinderClass); let service = new MessageResponder(mockedPingFinderInstance);

Agora que temos os mocks configurados, podemos definir qual deve ser o resultado das chamadas isPing() e verificar as chamadas reply() . O ponto é que em testes de unidade, definimos o resultado da chamada isPing() : true ou false . Não importa qual seja o conteúdo da mensagem, então nos testes usamos apenas "Non-empty string" .

 when(mockedPingFinderClass.isPing("Non-empty string")).thenReturn(true); await service.handle(mockedMessageInstance) verify(mockedMessageClass.reply('pong!')).once();

Veja como todo o conjunto de testes poderia se parecer:

 import "reflect-metadata"; import 'mocha'; import {expect} from 'chai'; import {PingFinder} from "../../../src/services/ping-finder"; import {MessageResponder} from "../../../src/services/message-responder"; import {instance, mock, verify, when} from "ts-mockito"; import {Message} from "discord.js"; describe('MessageResponder', () => { let mockedPingFinderClass: PingFinder; let mockedPingFinderInstance: PingFinder; let mockedMessageClass: Message; let mockedMessageInstance: Message; let service: MessageResponder; beforeEach(() => { mockedPingFinderClass = mock(PingFinder); mockedPingFinderInstance = instance(mockedPingFinderClass); mockedMessageClass = mock(Message); mockedMessageInstance = instance(mockedMessageClass); setMessageContents(); service = new MessageResponder(mockedPingFinderInstance); }) it('should reply', async () => { whenIsPingThenReturn(true); await service.handle(mockedMessageInstance); verify(mockedMessageClass.reply('pong!')).once(); }) it('should not reply', async () => { whenIsPingThenReturn(false); await service.handle(mockedMessageInstance).then(() => { // Successful promise is unexpected, so we fail the test expect.fail('Unexpected promise'); }).catch(() => { // Rejected promise is expected, so nothing happens here }); verify(mockedMessageClass.reply('pong!')).never(); }) function setMessageContents() { mockedMessageInstance.content = "Non-empty string"; } function whenIsPingThenReturn(result: boolean) { when(mockedPingFinderClass.isPing("Non-empty string")).thenReturn(result); } });

Os testes para PingFinder são bastante triviais, pois não há dependências para serem zombadas. Aqui está um exemplo de caso de teste:

 describe('PingFinder', () => { let service: PingFinder; beforeEach(() => { service = new PingFinder(); }) it('should find "ping" in the string', () => { expect(service.isPing("ping")).to.be.true }) });

Criando testes de integração

Além dos testes unitários, também podemos escrever testes de integração. A principal diferença é que as dependências nesses testes não são simuladas. No entanto, existem algumas dependências que não devem ser testadas, como conexões externas de API. Nesse caso, podemos criar mocks e rebind -los ao contêiner, para que o mock seja injetado. Aqui está um exemplo de como fazer isso:

 import container from "../../inversify.config"; import {TYPES} from "../../src/types"; // ... describe('Bot', () => { let discordMock: Client; let discordInstance: Client; let bot: Bot; beforeEach(() => { discordMock = mock(Client); discordInstance = instance(discordMock); container.rebind<Client>(TYPES.Client) .toConstantValue(discordInstance); bot = container.get<Bot>(TYPES.Bot); }); // Test cases here });

Isso nos leva ao final do nosso tutorial de bot do Discord. Parabéns, você o construiu de forma limpa, com TypeScript e DI em vigor desde o início! Este exemplo de injeção de dependência do TypeScript é um padrão que você pode adicionar ao seu repertório para uso com qualquer projeto.

TypeScript e injeção de dependência: não apenas para o desenvolvimento do Discord Bot

Trazer o mundo orientado a objetos do TypeScript para o JavaScript é um grande aprimoramento, quer estejamos trabalhando em código front-end ou back-end. Apenas usar tipos sozinho nos permite evitar muitos bugs. Ter injeção de dependência no TypeScript leva ainda mais práticas recomendadas orientadas a objetos para o desenvolvimento baseado em JavaScript.

É claro que, devido às limitações da linguagem, nunca será tão fácil e natural quanto em linguagens de tipagem estática. Mas uma coisa é certa: TypeScript, testes de unidade e injeção de dependência nos permitem escrever código mais legível, de acoplamento flexível e de fácil manutenção, independentemente do tipo de aplicativo que estamos desenvolvendo.

Relacionado: Crie um chatbot do WhatsApp, não um aplicativo