curso-tdd

Curso de desarrollo para asegurar la calidad del software


Project maintained by JJ Hosted on GitHub Pages — Theme by mattgraham

Inversión de dependencias

Planteamiento

La implantación de calidad integral fuerza a veces a tomar decisiones de arquitectura que permitan testear fácilmente cada una de las partes de un sistema, consiguiendo el máximo desacoplamiento.

A la vez, el principio de inversión de dependencias es uno de los principios SOLID. Así que merece la pena conocerlo, así como las decisiones de diseño que están detrás de él.

Al final de esta sesión

Habrá un grupo de clases con acceso a datos o algún servicio externo, la arquitectura reflejará este principio y se habrá testeado adecuadamente.

Criterio de aceptación

El repositorio tiene que estar corriendo los tests en Travis, y esos tests deben pasar; tendrá que haber una clase abstracta que se pueda inyectar dentro de nuestras clases para acceder a datos.

Roles o mixins

Vamos primero a entender los principios generales que se suelen usar, independientemente de frameworks externos, para implementar la inyección de dependencias, y a continuación veremos diferentes técnicas usadas para tal inyección de dependencias. Finalmente, veremos cómo se usan dobles de test, a través de esas inyecciones de dependencias, para llevar a cabo diferentes técnicas de testing.

En general, los roles o mixins se componen de un interfaz y, en ocasiones, de una implementación. Se usan en composición de objetos: un objeto compone, o implementa, diferentes roles, tomando todos los métodos y atributos de cada uno de los roles que componga.

Es una técnica de programación dirigida a objetos alternativa a la herencia para creación de nuevas clases. Mientras que en la herencia se heredan los atributos y métodos públicos, que se extienden o reimplementan, en composición se incluye en la clase así creada tanto el interfaz como la implementación, permitiendo creación de objetos más complejos con la característica principal de tener el mismo API que los roles que lo componen.

Por ejemplo, podemos definir este rol en Raku:

unit role Project::Dator;

method load() {...}
method update( \data ) {...}

Los {...} son stubs que indican que, quien quiera que implemente ese rol, tiene forzosamente que implementar estos métodos. El slash o barra invertida delante del argumento data indica que se podrá usar cualquier tipo de contenedor, sin fijar ni tipo ni roles.

En realidad, quien quiera que quiera instanciar una clase que implemente ese rol, pero la idea es la misma.

Este rol define solamente un interfaz, pero como las funciones son abstractas, sabemos que quien quiera que implemente (o mezcle) ese rol va a tener esas dos funciones. Podemos implementarlo en una clase, por ejemplo:

use JSON::Fast;

use Project::Dator;

unit class Project::Data::JSON does Project::Dator;

has $!file-name;
has $!data;

method new( $file-name where $file-name.IO ~~ :e ) {
    return self.bless( :$file-name, data => from-json slurp $file-name );
}

submethod BUILD( :$!file-name, :$!data) {}

method load() { $!data }
method update( \data ) { $!data = data }

Esta clase does (o sea, “hace” o “implementa”) el rol anterior, e implementa los dos métodos que tiene que implementar obligatoriamente. El principio de sustitución de Lyskov (la regla básica de programación dirigida a objetos) se aplica también aquí: donde quiera que usemos un rol, se puede usar también cualquier clase que implemente ese rol, por lo que podemos declarar argumentos como Project::Dator sabiendo que vamos a poder usar esas dos funciones, load y update. Lo haremos a continuación.

En Ruby se definen como modules los mixins o roles; un módulo en Ruby puede incluir tanto atributos como implementación, pero no se puede instanciar como en el caso de Raku. Por ejemplo, aquí:

module IssueStatus
  OPEN = 1
  CLOSED = 2
end

module Named
  attr_reader :name
end

class Project
  include Named
  attr_reader :issues, :milestones

  def initialize( name )
    @name = name
    @issues = []
    @milestones = []
  end

  class Issue
    include Named
    attr_reader :status

    def initialize( name )
      @name = name
    end
  end

end

Definimos el módulo Named. Ya que cada una de las clases tiene un ID o nombre, simplemente usamos en todos el mismo para que tenga un acceso uniforme; de esa forma también podríamos, por ejemplo, buscar por nombres de cosas, sin tener en cuenta si son uno u otro. Con include Named lo incluimos en la clase Project e Issue, que son las dos que hemos definido. Como ese módulo sólo define un interfaz (un lector de atributo), inicializar las variables de instancia es cosa de cada inicializador.

Obsérvese que el ámbito de la clase Issue es el de la otra clase. En este caso hemos decidido incluirlo todo en la misma clase.

Inyectando dependencias

Este principio se basa en el uso, dentro de las clases que necesitan las dependencias, de objetos que las representen, en vez de acoplar clases (y objetos) a las dependencias de forma rígida. Si estas dependencias implementan un rol, podemos intercambiarlas fácilmente sin que la clase que los usa lo detecte.

Por ejemplo, podemos hacerlo en esta clase, Project::Stored:

unit class Project::Stored does Project;

has Project::Dator $!dator;

# Código de la clase aquí abajo

Project lo hemos convertido también en un rol para que sea más fácil componerlo en otras clases; Project, como tal rol, se comporta de la misma forma que una clase si se le usa en este contexto, pero haciéndolo así es más fácil usar todas las variables privadas, que pasan directamente a formar parte de la nueva clase. Dentro de esa clase, sin embargo, tenemos a $!dator, que implementa el rol Project::Dator y por tanto se puede instanciar con cualquier tipo de objeto que siga ese rol.

La inyección de dependencias puede funcionar de esta forma:

my $dator = Project::Data::JSON.new($data-file);
my $stored = Project::Stored.new($dator);

En esta nueva clase tendremos que adaptar las funciones para usar este tipo de almacenamiento de datos (en vez de almacenarlos en memoria), pero en principio va a ser posible hacerlo sin mucho problema.

Dobles de test

El concepto de dobles de test es un patrón que incluye todos los posibles objetos falsos que se usen para sustituir a verdaderos objetos que sean costosos de instanciar o, generalmente, tengan alguna dependencia externa.

Hay muchos documentos donde aclara los conceptos de “dobles de test”; se puede consultar, por ejemplo, este en el blog de testing de Google.

El tipo de doble más simple es lo que se llama un dummy o “pelele”. Es simplemente un objeto que se utiliza como placeholder en una llamada a función o instanciación de un objeto, sin que en realidad haga nada.

Stub: lo que queda en un ticket o cheque troquelado, o una colilla.

Por ejemplo, esta clase NoLogger es simplemente un stub:

require "project"

PROJECT_NAME = 'Foo'

class NoLogger
end

describe Project do

  before do
    @project = Project.new(PROJECT_NAME,NoLogger.new() )
  end
# continúa
end

Como Ruby tiene duck typing, se puede pasar cualquier cosa en realidad. Evidentemente luego no se podrá usar; aunque se puede elevar su rango a stub, que al menos tienen una implementación mínima de un interfaz para que puedan ser llamadas.

Obsérvese también en esta biblioteca, RSpec, el uso de before para setup de la clase que vamos a testear.

Hay otros tipos de dobles de test. Por ejemplo, todas las aplicaciones van a usar algún tipo de almacenamiento. Si queremos testear el comportamiento de una aplicación, simplemente tenemos que usar el mismo rol:

unit class Project::Data::Fake does Project::Dator;

has $!data = { "milestones" => [
    {
        "2" => [
            {
                "4" => "Closed"
            },
            {
                "3" => "Open"
            }
        ]
    }
    ],
    "name" => "Foo"
};

Se puede instanciar este objeto exactamente de la misma forma para “imitar” la clase original que da acceso a datos:

$dator = Project::Data::Fake.new;
$stored = Project::Stored.new($dator);

Este tipo de dobles se llaman fakes, o simplemente falsos. No tienen ninguna lógica de negocio, sino que simplemente tienen lo necesario para responder de forma razonable a las entradas que se produzcan.

En general, la mayoría de los dobles de test entran dentro del concepto de mock (imitación o maqueta), que se puede extender también a cualquier tipo de objeto que se use para imitar instancias de objetos que no tienen por qué estar presentes en el momento del test. Los mocks pueden incluir también una implementación completa, sólo que una que no esté lista para producción.

Por ejemplo, este artículo muestra de forma extensa cómo usar mocks en Python. Algunos frameworks como Jest permiten también hacer mocks de forma sencilla.

Los que hemos visto con anterioridad en realidad no pueden fallar: se les llama de una forma determinada, devuelven algo. Sin embargo, en el punto más alto del espectro están los mocks: un mock sí puede provocar que el test falle, o puede devolver algún tipo de error si se le invoca con alguna combinación equivocada; esencialmente, los mocks se usan para comprobar interacciones entre diferentes objetos. Por ejemplo, si un mock de un sistema de logging usa siempre un URI para instanciarse, puede fallar si se usa cualquier otro tipo de cadena u objeto.

A dónde ir desde aquí

Una vez que las dependencias están bien integradas en la arquitectura, se pueden por supuesto hacer los tests de integración.

La idea de dobles de test parte de un libro de Meszaros sobre patrones de test; Martin Fowler explica en este artículo el concepto de forma extensiva.

El desacoplamiento entre diferentes partes de un programa es la proposición central de la arquitectura hexagonal, que esencialmente trata de evitar dependencias no deseadas entre diferentes partes de una aplicación.

Actividad

Lo esencial de este hito es añadir un servicio externo usando el principio de inyección de dependencias. Puede ser un servicio de descarga de datos, o puede ser un servicio de almacenamiento de datos; en realidad, el principio es el mismo. Tanto la clase que se encargue de los datos como la clase con el manejador de datos (dateador) insertado tendrán que testearse.

Tendréis que añadir al fichero agil.yaml una hueva clave, dateador, cuyo valor sea el fichero donde habéis implementado la clase abstracta que sirva de tal, o alguna clase concreta que siga ese patrón.