Test d’intégration d’une application REST avec Spring Boot

L’article montre comment construire une application Spring Boot exposant un service REST en étant guidé par un test d’intégration.

On va développer un service simple de mise à jour et de recherche de personnes. La démarche TDD est applicable.

Spring Boot permet d’utiliser les versions des spécifications de Java EE 7, à savoir JPA 2.1 (hibernate provider), JAX-RS 2 (jersey provider) et Bean Validation.

Le code source complet est disponible dans Github

Configuration maven

Ajout des dépendances Spring Boot starter web, jersey, data-jpa et test.

Ajout du Spring Boot maven plugin pour gérer le packaging et l’exécution « in-place ».

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>1.2.3.RELEASE</version>
</parent>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jersey</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
  </dependency>
  <dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.0.0</version>
    <scope>test</scope>
  </dependency>
</dependencies>

<build>
  <plugins>
    <plugin>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-maven-plugin</artifactId>
    </plugin>
  </plugins>
</build>

Configuration de l’application REST

Mise en place de la configuration jersey et du bootstrap de l’application.

@SpringBootApplication
public class RestApplication {

  @Bean
  public ResourceConfig jerseyConfig() {
    ResourceConfig resourceConfig = new ResourceConfig();
    resourceConfig.property(ServerProperties.BV_SEND_ERROR_IN_RESPONSE, true);
    resourceConfig.packages(RestApplication.class.getPackage().getName());
    return resourceConfig;
  }

  public static void main(String[] args) {
    SpringApplication.run(RestApplication.class, args);
  }

}

Test d’intégration du service REST

Au lancement du test, l’application est servie par un Tomcat sur un port aléatoire non utilisé.

Spring Data JPA exporte le schéma SQL dans la base de données mémoire H2.

Il n’y a plus qu’à écrire des tests basés sur des clients REST.

package com.giovanetti;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.IntegrationTest;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.boot.test.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.web.client.RestTemplate;
import javax.inject.Inject;
import javax.ws.rs.core.UriBuilder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = RestApplication.class)
@WebAppConfiguration
@IntegrationTest({"server.port=0"}) //random unassigned port
public class PersonneServiceIT {
@Value("${local.server.port}") //actual random port
private int port;
@Inject
private PersonneRepository personneRepository;
private String baseUrl = "http://localhost:&quot;;
private RestTemplate template;
@Before
public void setUp() {
this.baseUrl = "http://localhost:&quot; + port;
template = new TestRestTemplate();
personneRepository.deleteAll();
personneRepository.save(new Personne("prenom1","nom1"));
personneRepository.save(new Personne("prenom2","nom2"));
}
@Test
public void findByNom() {
List<Personne> response = Arrays.asList(template.getForObject(UriBuilder.
fromUri(baseUrl).path("/personnes/search").queryParam("nom", "nom1").build(), Personne[].class));
assertThat(response).hasSize(1).extracting("nom", "prenom")
.contains(tuple("nom1", "prenom1"));
}
@Test
public void findByNom_MinSizeViolation() {
ResponseEntity<ArrayList> response = template.getForEntity(UriBuilder.
fromUri(baseUrl).path("/personnes/search").queryParam("nom", "x").build(), ArrayList.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
checkSizeViolation(response.getBody());
}
@Test
public void postPersonne() {
ResponseEntity response = template.postForEntity(UriBuilder.
fromUri(baseUrl).path("/personnes").build(), new Personne("prenom3", "nom3"), null);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(personneRepository.findAll()).hasSize(3)
.extracting("nom", "prenom")
.contains(tuple("nom3", "prenom3"));
}
@Test
public void postPersonne_MinSizeViolation() {
ResponseEntity<ArrayList> response = template.postForEntity(UriBuilder.
fromUri(baseUrl).path("/personnes").build(), new Personne("prenom3", "x"), ArrayList.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
checkSizeViolation(response.getBody());
}
private void checkSizeViolation(ArrayList<Map> body) {
assertThat(body).hasSize(1).hasOnlyElementsOfType(Map.class);
assertThat(body.get(0)).containsEntry("messageTemplate",
"{javax.validation.constraints.Size.message}");
}
}

Entity Personne JPA

@Entity
public class Personne {

  private Personne() {}

  public Personne(String prenom, String nom) {
    this.prenom = prenom;
    this.nom = nom;
  }

  @Id
  @GeneratedValue(strategy= GenerationType.AUTO)
  @JsonIgnore
  private long id;

  private String prenom;

  @Size(min=2)
  private String nom;

  public String getPrenom() {
    return prenom;
  }

  public String getNom() {
    return nom;
  }
}

Repository Personne Spring Data JPA

Rien de plus à écrire dans le cas d’un CRUD.

 public interface PersonneRepository extends CrudRepository<Personne,Long> {

   List<Personne> findPersonneByNom(String nom);

 }

Service REST

A noter que JAX-RS 2 s’intègre à Bean Validation de manière à déclencher les validations automatiquement lors des appels REST.

@Path("/personnes")
@Produces({MediaType.APPLICATION_JSON})
public class PersonneService {

  @Inject
  private PersonneRepository personneRepository;

  @GET
  @Path("/search")
  public Iterable<Personne> findPersonnesByNom(@Size(min=2) @QueryParam("nom") String nom) {
   return personneRepository.findPersonneByNom(nom);
  }

  @POST
  public Response savePersonne(@Valid Personne personne) {
    personneRepository.save(personne);
    return Response.status(Response.Status.CREATED).build();
  }

}
Publicité

Packager et exécuter un job Spring Batch avec Spring Boot

Cet article fait suite aux précédents posts sur TDD et BDD avec Spring Batch.

Le but est de packager et exécuter directement un batch Spring Batch sans ajout de configuration spécifique de packaging ni d’installation préalable d’environnement d’exécution particulier.

Le projet Github springbatch-sample montre comment Spring Boot permet de construire de manière simple un livrable auto exécutable.

La documentation de référence spring détaille les étapes pour un projet avec une seule datasource contenant un seul job et sans paramètres de lancement.

On va voir dans l’article les configurations Spring Boot nécessaires pour lancer un job d’alimentation de données dans une base, avec datasources technique et fonctionnelle séparées et comment traiter les paramètres d’entrée et propriétés système.

Configuration maven

Déclaration projet parent Spring Boot


<parent>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-parent</artifactId>
   <version>1.2.2.RELEASE</version>
</parent>

Ajout dépendance maven Spring Boot pour les batchs (plus besoin de déclarer les dépendances spring)


<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-batch</artifactId>
</dependency>

Ajout du plugin spring boot

 

<plugin>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-maven-plugin</artifactId>
</plugin>

Configuration java du runner

package com.giovanetti.sample.batch.job;
import com.giovanetti.support.batch.annotations.TechnicalDataSource;
import com.giovanetti.support.batch.function.Consumer;
import com.giovanetti.support.batch.function.Function;
import org.springframework.batch.core.explore.JobExplorer;
import org.springframework.batch.core.explore.support.JobExplorerFactoryBean;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration;
import org.springframework.boot.autoconfigure.batch.JobLauncherCommandLineRunner;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
@Configuration
@EnableAutoConfiguration(exclude={DataSourceTransactionManagerAutoConfiguration.class, BatchAutoConfiguration.class, DataSourceAutoConfiguration.class})
@SpringBootApplication
public class BatchApplication {
@Bean
public JobExplorer jobExplorer(@TechnicalDataSource DataSource dataSource) {
JobExplorerFactoryBean factory = new JobExplorerFactoryBean();
factory.setDataSource(dataSource);
Consumer.acceptWithRawException(factory, JobExplorerFactoryBean::afterPropertiesSet);
return Function.applyWithRawException(factory, FactoryBean::getObject);
}
@Bean
public JobLauncherCommandLineRunner jobLauncherCommandLineRunner(JobLauncher jobLauncher, JobExplorer jobExplorer, @Value(
"${job.name}") String jobName) {
JobLauncherCommandLineRunner runner = new JobLauncherCommandLineRunner(jobLauncher, jobExplorer);
runner.setJobNames(jobName);
return runner;
}
public static void main(String[] args) {
System.exit(SpringApplication.exit(SpringApplication.run(BatchApplication.class, args)));
}
}

L’écriture de la configuration des beans JobExplorer et JobLauncherCommandLineRunner n’est pas nécessaire dans le cas de projets mono job et mono datasource.

Test du runner à vide

package com.giovanetti.sample.batch.job;
import com.giovanetti.support.batch.configuration.GenericTestConfiguration;
import com.giovanetti.support.batch.rule.BatchProperties;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.contrib.java.lang.system.ExpectedSystemExit;
import org.junit.contrib.java.lang.system.ProvideSystemProperty;
import org.junit.rules.TemporaryFolder;
import org.springframework.batch.core.launch.support.ExitCodeMapper;
import java.io.IOException;
public class BatchApplicationAlimentationIT {
private final static String FUNCTIONAL_SCRIPT = "schema-functional.sql";
@ClassRule
public final static BatchProperties batchProperties = BatchProperties.getDefault();
@ClassRule
public static ProvideSystemProperty systemProperty = new ProvideSystemProperty("job.name", JobAlimentationConfiguration.JOB_NAME);
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
/**
* @see <a href="http://www.stefan-birkner.de/system-rules/index.html">System Rules</a>
*/
@Rule
public final ExpectedSystemExit exit = ExpectedSystemExit.none();
@Before
public void before() {
GenericTestConfiguration.buildFunctionalDataSource(FUNCTIONAL_SCRIPT);
GenericTestConfiguration.buildTechnicalDataSource();
}
@Test
public void run() throws IOException {
exit.expectSystemExitWithStatus(ExitCodeMapper.JVM_EXITCODE_COMPLETED);
BatchApplication.main(
new String[]{JobAlimentationConfiguration.INPUT_FILE_PARAMETER + "=" + temporaryFolder.newFile()
.getPath()});
}
@Test(expected = IllegalStateException.class)
public void run_SiParametreInvalide_AlorsExitWithError() {
BatchApplication.main(new String[]{});
}
}

La librairie system rule permet de gérer de manière simple et fiable les propriétés système et les codes de sortie.

Packager le livrable jar exécutable

maven clean package

Pré-requis d’environnement pour exécuter le job d’alimentation

  • Une datasource technique
  • Une datasource fonctionnelle
  • Un fichier de données csv
  • Un fichier de propriétés contenant les clés
    • ds.technical.driverclassname
    • ds.technical.url
    • ds.technical.username
    • ds.technical.password
    • ds.functional.driverclassname
    • ds.functional.url
    • ds.functional.username
    • ds.functional.password
    • commit.interval

Exécuter le jar

avec maven

mvn spring-boot:run -Drun.arguments=input.file.path=alimentation.csv

plus les propriétés de VM -Dbatch.properties.path=file:batch.properties -Djob.name=alimentationJob

ou bien directement le jar

java -Dbatch.properties.path=file:batch.properties -Djob.name=alimentationJob -jar springbatch-sample.jar input.file.path=alimentation.csv

Voir la documentation de référence spring pour les détails des jar exécutable Spring Boot.

BDD Spring Batch

Voici BDD appliqué à Spring Batch, le batch d’extraction sur Github springbatch-sample sert de support.

Cucumber-JVM a permis d’exécuter et d’implémenter très simplement les étapes correspondantes à des scénarios décrits dans le formalisme BDD.

Etant donnée les 2 scénarios d’extraction en langage Gherkin

Feature: batch extraction+
Scenario: scenario 1
Given les utilisateurs
|Id|Nom|Prenom|
|1|nom1|prenom1|
|2|nom2|prenom2|
When je charge les utilisateurs en base de données
And j'execute le job d'extraction
Then mon fichier de sortie contient les lignes
|1,prenom1,nom1|
|2,prenom2,nom2|
Scenario: scenario 2
Given les utilisateurs
|Id|Nom|Prenom|
|1|nom1|prenom1|
When je charge les utilisateurs en base de données
And j'execute le job d'extraction
Then mon fichier de sortie contient les lignes
|1,prenom1,nom1|

On configure le runner Junit

package com.giovanetti.sample.batch.job.cucumber;
import com.giovanetti.support.batch.rule.BatchProperties;
import cucumber.api.CucumberOptions;
import cucumber.api.junit.Cucumber;
import org.junit.BeforeClass;
import org.junit.runner.RunWith;
@RunWith(Cucumber.class)
@CucumberOptions(format = {"pretty", "html:target/cucumber-report"},
glue = "com.giovanetti.sample.batch.job.cucumber")
public class ExtractionFeatureTest {
@BeforeClass
public static void setupClass() {
BatchProperties.getDefault().create();
}
}

Et on implémente les étapes

Dbsetup permet d’insérer directement les données des scénarios en base de données.

package com.giovanetti.sample.batch.job.cucumber;
import com.giovanetti.sample.batch.configuration.JobExtractionTestConfiguration;
import com.giovanetti.sample.batch.item.User;
import com.giovanetti.sample.batch.job.JobExtractionConfiguration;
import com.giovanetti.support.batch.annotations.FunctionalDataSource;
import com.ninja_squad.dbsetup.DbSetup;
import com.ninja_squad.dbsetup.destination.DataSourceDestination;
import com.ninja_squad.dbsetup.operation.Insert;
import cucumber.api.DataTable;
import cucumber.api.java.After;
import cucumber.api.java.Before;
import cucumber.api.java.en.Given;
import cucumber.api.java.en.Then;
import cucumber.api.java.en.When;
import org.apache.commons.io.FileUtils;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.test.JobLauncherTestUtils;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ContextConfiguration;
import javax.inject.Inject;
import javax.sql.DataSource;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.List;
import static com.ninja_squad.dbsetup.Operations.insertInto;
import static org.assertj.core.api.Assertions.assertThat;
@ContextConfiguration(classes = {JobExtractionTestConfiguration.class, JobLauncherTestUtils.class})
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
public class ExtractionSteps {
private static final String USER_TABLE = "USER";
private static final String[] USER_COLUMNS = new String[]{"ID", "NOM", "PRENOM"};
private File outputFile;
private DataSourceDestination destination;
@Inject
@FunctionalDataSource
private void setDataSourceDestination(DataSource dataSource) {
destination = new DataSourceDestination((dataSource));
}
@Inject
private JobLauncherTestUtils jobLauncherTestUtils;
private List<User> users;
@Before
public void createOutputFile() throws IOException {
outputFile = createTempFile();
}
private File createTempFile() throws IOException {
File tempFolder = File.createTempFile("junit", "");
if (tempFolder.delete() && tempFolder.mkdir()) {
return File.createTempFile("junit", null, tempFolder);
} else {
throw new IllegalStateException("createTempFile fail");
}
}
@After
public void deleteOutputFile() throws IOException {
FileUtils.deleteDirectory(outputFile.getParentFile());
}
@Given("^les utilisateurs$")
public void les_utilisateurs(DataTable table) {
users = table.asList(User.class);
}
@When("^je charge les utilisateurs en base de données$")
public void je_charge_les_utilisateurs_en_base_de_données() {
Insert.Builder insertBuilder = insertInto(USER_TABLE).columns(USER_COLUMNS);
users.forEach(user -> insertBuilder.values(user.getId(), user.getNom(), user.getPrenom()));
new DbSetup(destination, insertBuilder.build()).launch();
}
@When("^j'execute le job d'extraction$")
public void j_execute_le_job_d_extraction() throws Exception {
jobLauncherTestUtils.launchJob(
new JobParametersBuilder().addString(JobExtractionConfiguration.OUTPUT_FILE_PARAMETER,
outputFile.getPath()).toJobParameters());
}
@Then("^mon fichier de sortie contient les lignes$")
public void mon_fichier_de_sortie_contient_les_lignes(List<String> lines) throws IOException {
assertThat(Files.readAllLines(outputFile.toPath())).hasSize(lines.size()).containsAll(lines);
}
}

Concernant l’alternative JBehave en tant que framework BDD à la place de Cucumber, la mise en place s’est avérée moins rapide et la configuration plus verbeuse, un exemple est aussi disponible dans springbatch-sample

TDD Spring Batch

Ici TDD s’applique à un batch Spring Batch, un batch d’extraction est pris comme exemple pour appliquer la démarche.

Les sources complètes sont sur Github : springbatch-sample

Voici la spécification du batch d’extraction d’une base de données dans un fichier plat :

1 Job contient

  • 1 validateur de présence du paramètre chemin du fichier en sortie
  • 1 étape contient
    • 1 reader jdbc
    • 1 writer fichier plat

Le plan de test du batch est le suivant

  • TDD reader jdbc et ensuite
  • TDD writer fichier plat et ensuite
  • TDD job extraction

Le SUT se présente sous la forme de classes java de configuration spring (aucun xml n’a été utilisé…)

Des classes génériques de configuration et des commodités de test telles que des Rules Junit et des templates de reader et writer sont fournies par le module springbatch-support.

On déroule le plan de test, d’abord le TDD du reader jdbc

package com.giovanetti.sample.batch.job;
import com.giovanetti.sample.batch.configuration.JobExtractionTestConfiguration;
import com.giovanetti.sample.batch.item.User;
import com.giovanetti.support.batch.rule.BatchProperties;
import com.giovanetti.support.batch.rule.DBUnitRule;
import com.giovanetti.support.batch.template.ItemReaderTemplate;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.annotation.DirtiesContext.ClassMode;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import javax.inject.Inject;
import static com.giovanetti.sample.batch.item.ItemHelper.listOf2Users;
import static org.assertj.core.api.Assertions.assertThat;
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {JobExtractionTestConfiguration.class})
@DirtiesContext(classMode = ClassMode.AFTER_CLASS)
public class JdbcCursorItemReaderTest {
@ClassRule
public final static BatchProperties batchProperties = BatchProperties.getDefault();
@Rule
@Inject
public DBUnitRule dbUnitRule;
@Inject
private ItemReaderTemplate<User> itemReader;
@Test
public void databaseInitialisationOK() {
assertThat(dbUnitRule.rowCountFrom("USER")).isEqualTo(2);
}
@Test
public void read() {
assertThat(itemReader.readAll())
.hasSize(2)
.usingFieldByFieldElementComparator()
.containsAll(listOf2Users());
}
}

Quelques explications sur le test du reader jdbc

  • JobExtractionTestConfiguration crée des beans spring utilitaires pour les tests et importe la classe à tester JobExtractionConfiguration
  • La rule BatchProperties créée un fichier de properties temporaire pour configurer les datasources
    • une datasource pour la base de données technique Spring Batch
    • une datasource fonctionnelle pour lire les données à extraire
  • La rule DBunit insère les données de test dans une base mémoire
  • Le composant ItemReaderTemplate permet de récupérer tous les items de la base de données

On enchaîne sur le TDD du writer fichier plat

package com.giovanetti.sample.batch.job;
import com.giovanetti.sample.batch.configuration.JobExtractionTestConfiguration;
import com.giovanetti.sample.batch.item.User;
import com.giovanetti.support.batch.rule.BatchProperties;
import com.giovanetti.support.batch.template.ItemWriterTemplate;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.test.MetaDataInstanceFactory;
import org.springframework.batch.test.StepScopeTestExecutionListener;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.annotation.DirtiesContext.ClassMode;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;
import org.springframework.test.context.support.DirtiesContextTestExecutionListener;
import javax.inject.Inject;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import static com.giovanetti.sample.batch.item.ItemHelper.listOf2Users;
import static org.assertj.core.api.Assertions.assertThat;
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {JobExtractionTestConfiguration.class})
@TestExecutionListeners(
{DependencyInjectionTestExecutionListener.class, StepScopeTestExecutionListener.class, DirtiesContextTestExecutionListener.class})
@DirtiesContext(classMode = ClassMode.AFTER_CLASS)
public class FlatFileItemWriterTest {
public static StepExecution getStepExecution() throws IOException {
outputFile = outputRule.newFile();
return MetaDataInstanceFactory.createStepExecution(
new JobParametersBuilder().addString(JobExtractionConfiguration.OUTPUT_FILE_PARAMETER,
outputFile.getPath()).toJobParameters());
}
private static File outputFile;
@ClassRule
public final static TemporaryFolder outputRule = new TemporaryFolder();
@ClassRule
public final static BatchProperties batchProperties = BatchProperties.getDefault();
@Inject
private ItemWriterTemplate<User> itemWriter;
@Test
public void write() throws IOException {
// Act
itemWriter.write(listOf2Users());
// Assert
assertThat(Files.readAllLines(outputFile.toPath()))
.hasSize(2)
.contains("1,prenom1,nom1", "2,prenom2,nom2");
}
}
  • La méthode static getStepExecution est nécessaire pour injecter tardivement (late binding) le paramètre de chemin du fichier de sortie
  • La rule TemporaryFolder permet de créer un fichier de sortie temporaire
  • Le composant ItemWriterTemplate permet d’écrirer tous les items dans le fichier de sortie

Et on finit par le TDD du job extraction

package com.giovanetti.sample.batch.job;
import com.giovanetti.sample.batch.configuration.JobExtractionTestConfiguration;
import com.giovanetti.support.batch.ExternalConfiguration;
import com.giovanetti.support.batch.rule.BatchProperties;
import com.giovanetti.support.batch.rule.DBUnitRule;
import com.google.common.collect.Iterables;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith;
import org.springframework.batch.core.*;
import org.springframework.batch.test.JobLauncherTestUtils;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.annotation.DirtiesContext.ClassMode;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import javax.inject.Inject;
import static org.assertj.core.api.Assertions.assertThat;
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {JobExtractionTestConfiguration.class, JobLauncherTestUtils.class})
@DirtiesContext(classMode = ClassMode.AFTER_CLASS)
public class JobExtractionTest {
@ClassRule
public final static TemporaryFolder outputFile = new TemporaryFolder();
@ClassRule
public final static BatchProperties batchProperties = new BatchProperties().addTechnicalHsql()
.addFunctionalHsql()
.add(ExternalConfiguration.StepPropertyKeys.COMMIT_INTERVAL.toString(), "1");
@Rule
@Inject
public DBUnitRule dbUnitRule;
@Inject
private JobLauncherTestUtils jobLauncherTestUtils;
@Test
public void jobExtraction() throws Exception {
// Act
JobExecution jobExecution = jobLauncherTestUtils.launchJob(
new JobParametersBuilder().addString(JobExtractionConfiguration.OUTPUT_FILE_PARAMETER,
outputFile.getRoot().getPath()).toJobParameters());
// Assert
assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
StepExecution stepExecution = Iterables.getOnlyElement(jobExecution.getStepExecutions());
assertThat(stepExecution.getReadCount()).isEqualTo(2);
assertThat(stepExecution.getWriteCount()).isEqualTo(2);
}
@Test(expected = JobParametersInvalidException.class)
public void jobExtraction_SiParametreInvalide_AlorsException() throws Exception {
jobLauncherTestUtils.launchJob();
}
}

Voilà, TDD permet de contrôler pas à pas et sans douleur la réalisation d’un batch Spring Batch.

Lorsque le dernier composant de test passe au vert alors le batch est conforme à la spécification, voir la solution complète sur Github : springbatch-sample.

Quant au code factorisé dans le module springbatch-support c’est la dernière étape de TDD (clean & refactor)  qui l’a fait émergé.

 

 

 

TDD modèle : partie 3

Dans cette troisième partie nous allons enrichir le modèle en prenant en compte des contraintes.

La technique utilisée est la validation par annotation via la jsr 303 : Bean Validation. L’implémentation retenue est celle d’hibernate : hibernate-validator qui est l’implémentation de référence de la jsr.

Dépendances maven à ajouter dans le pom.xml du projet :

<dependency>
	<groupId>org.hibernate</groupId>
	<artifactId>hibernate-validator</artifactId>
	<version>4.0.2.GA</version>
</dependency>
<dependency>
	<groupId>org.slf4j</groupId>
	<artifactId>slf4j-api</artifactId>
	<version>1.6.1</version>
</dependency>

On suppose que les spécifications demandent l’ajout des contraintes de validation suivantes :

  • Code produit doit être non null
  • Nom produit doit être non null
  • Longueur nom produit ne doit pas dépasser 20 caractères

On écrit les tests de validation dans ProduitTest :

private static Validator validator;

@BeforeClass
public static void setUp() {
	ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
	validator = factory.getValidator();
}

@Test
public void codeIsNullAlorsViolationContrainte() {
	Produit produitSansCode = produitAyantCode(null).nom("nouveau produit")
			.build();
	assertViolationContrainte(produitSansCode, "ne peut pas être nul");
}

@Test
public void nomIsNullAlorsViolationContrainte() {
	assertViolationContrainte(produitAyantCode("A").build(),
			"ne peut pas être nul");
}

@Test
public void nomPlusDe20CaracteresAlorsViolationContrainte() {
	Produit produit = produitAyantCode("A").nom(
			"nom plus de vingt caractères").build();
	assertViolationContrainte(produit, "la taille doit être entre 0 et 20");
}

private <T> void assertViolationContrainte(T obj, String message) {
	Set<ConstraintViolation<T>> constraintViolations = validator
			.validate(obj);
	assertEquals(1, constraintViolations.size());
	assertEquals(message, constraintViolations.iterator().next()
			.getMessage());
}

Pour faire passer les tests on annote la classe Produit :

private static final int TAILLE_MAX_NOM = 20;

@NotNull
private String code;

@NotNull
@Size(max = TAILLE_MAX_NOM)
private String nom;

C’est vert. On peut livrer mais il reste quand même une dette sur le modèle, il y a un todo sur la méthode equalsAuxLimites, il faudrait externaliser la méthode en tant que méthode static dans une classe ModelHelper.

Après refactoring on obtient pour la classe Produit :

public class Produit {

	private static final int DELAI_MOIS_MISE_LIGNE = 6;

	private static final int ODD_NUMBER = 31;

	private static final int TAILLE_MAX_NOM = 20;

	@NotNull
	private String code;

	@NotNull
	@Size(max = TAILLE_MAX_NOM)
	private String nom;

	private boolean innovant = true;

	private LocalDate dateMiseEnLigne = new LocalDate()
			.plusMonths(DELAI_MOIS_MISE_LIGNE);

	//omission des méthodes get et set pour lisibilité de l'exemple
        //...

	@Override
	public int hashCode() {
		return new HashCodeBuilder(ODD_NUMBER, 1)
                               .append(code).toHashCode();
	}

	@Override
	public boolean equals(final Object obj) {
		if (ModelHelper.isInstanceOf(obj, Produit.class)) {
			Produit other = (Produit) obj;
			return ModelHelper.equals(this, obj,
					new EqualsBuilder().append(this.code, other.code));
		}
		return false;
	}

}

et pour la classe ModelHelper on a :

public class ModelHelper {

	public static boolean isInstanceOf(final Object obj,
			Class objectClass) {
		return obj != null && objectClass.isInstance(obj);
	}

	public static boolean equals(final Object notNullObject,
			final Object other, EqualsBuilder equalsBuilder) {
		if (notNullObject == other) {
			return true;
		} else if (other == null) {
			return false;
		} else if (notNullObject.getClass() != other.getClass()) {
			return false;
		} else {
			return equalsBuilder.isEquals();
		}
	}
}

On refactore également les tests et on obtient finalement pour ProduitTest :

public class ProduitTest {

	private static Validator validator;

	private static final LocalDate TRENTE_MARS_2012 = new LocalDate(2012, 3, 30);

	@BeforeClass
	public static void setUp() {
		ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
		validator = factory.getValidator();
	}

	@Test
	public void builder() {
		Produit produit = produitAyantCode("A").nom("builder").innovant(false)
				.miseEnLigne(TRENTE_MARS_2012).build();
		assertEquals("A", produit.getCode());
		assertEquals("builder", produit.getNom());
		assertEquals(TRENTE_MARS_2012, produit.getDateMiseEnLigne());
		assertFalse(produit.isInnovant());
	}

	@Test
	public void nouveauProduitEstInnovant() {
		assertTrue(new Produit().isInnovant());
	}

	@Test
	public void nouveauProduitEstDispoDans6Mois() {
		assertEquals(new LocalDate().plusMonths(6),
				new Produit().getDateMiseEnLigne());
	}

	@Test
	public void deuxProduitMemeCodeSontEgaux() {
		assertEquals(
				produitAyantCode("A").nom("produit top")
						.miseEnLigne(TRENTE_MARS_2012).build(),
				produitAyantCode("A").nom("produit super").innovant(false)
						.build());
	}

	@Test
	public void deuxProduitCodesDifferentsNonEgaux() {
		assertFalse(produitAyantCode("A").nom("nom produit A").build()
				.equals(produitAyantCode("B").nom("nom produit A").build()));
	}

	@Test
	public void equals_InstanceDifferentProduitAlorsFalse() {
		assertFalse(new Produit().equals(new Object()));
	}

	@Test
	public void deuxProduitsEgauxOntMemeHashCode() {
		// respect du contrat general methode hashcode
		// pre-assert
		assertEquals(produitAyantCode("A").build(), produitAyantCode("A")
				.build());
		assertFalse(produitAyantCode("A").build() == produitAyantCode("A")
				.build());

		// assert
		assertTrue(produitAyantCode("A").build().hashCode() == produitAyantCode(
				"A").build().hashCode());
	}

	@Test
	public void deuxProduitsDifferentsOntHashCodeDifferents() {
		// pour des raisons d'optimisation
		assertFalse(produitAyantCode("A").build().hashCode() == produitAyantCode(
				"B").build().hashCode());
	}

	@Test
	public void codeIsNullAlorsViolationContrainte() {
		Produit produitSansCode = produitAyantCode(null).nom("nouveau produit")
				.build();
		assertViolationContrainte(produitSansCode, "ne peut pas être nul");
	}

	@Test
	public void nomIsNullAlorsViolationContrainte() {
		assertViolationContrainte(produitAyantCode("A").build(),
				"ne peut pas être nul");
	}

	@Test
	public void nomPlusDe20CaracteresAlorsViolationContrainte() {
		Produit produit = produitAyantCode("A").nom(
				"nom plus de vingt caractères").build();
		assertViolationContrainte(produit, "la taille doit être entre 0 et 20");
	}

	private <T> void assertViolationContrainte(T obj, String message) {
		Set<ConstraintViolation<T>> constraintViolations = validator
				.validate(obj);
		assertEquals(1, constraintViolations.size());
		assertEquals(message, constraintViolations.iterator().next()
				.getMessage());
	}

}

Et enfin ModelHelperTest :

public class ModelHelperTest {

	@Test
	public void isInstanceOf() {
		assertFalse(ModelHelper.isInstanceOf(null, Produit.class));
		assertFalse(ModelHelper.isInstanceOf("a string", Produit.class));
		assertTrue(ModelHelper.isInstanceOf(
				ProduitBuilder.produitAyantCode("code").build(), Produit.class));
	}

	@Test(expected = NullPointerException.class)
	public void equals_premierObjetNull_AlorsNPE() {
		// setup
		Produit produit = ProduitBuilder.produitAyantCode("code").build();

		// test && assert
		ModelHelper.equals(null, produit,
				new EqualsBuilder().append(null, produit.getCode()));
	}

	@Test
	public void equals() {
		// setup
		Produit produit = ProduitBuilder.produitAyantCode("code").build();
		Produit autreProduit = ProduitBuilder.produitAyantCode("code").build();
		EqualsBuilder equalsBuilder = new EqualsBuilder().append(
				produit.getCode(), autreProduit.getCode());

		// test && assert
		assertTrue(ModelHelper.equals(produit, autreProduit, equalsBuilder));
		assertTrue(ModelHelper.equals(produit, produit, equalsBuilder));
		assertFalse(ModelHelper.equals(produit, null, equalsBuilder));
		assertFalse(ModelHelper.equals(produit, new Object(), equalsBuilder));
		assertFalse(ModelHelper.equals(produit, new Produit() {
		}, equalsBuilder));
	}
}

Nous sommes allés plus loin que de coder directement une classe java, mais avec un effort modéré on perçoit déjà le gain en fiabilité et productivité  pour  la gestion de la persistance et les services métier futurs. En fait un bon nombre de préoccupations et de pratiques que l’on retrouve souvent dupliquées dans les différentes couches sont cadrées dans le coeur du métier. Un retour sur l’investissement en temps est déjà là puisque le design a fait émergé un ModelHelper apportant des fonctionnalités génériques au modèle, de plus les tests unitaires sont nettement plus faciles à écrire que si aucun effort n’avait été fait. En fin de compte une prise de recul au bon moment c’est beaucoup de temps gagné pour la suite avec une mise de départ très raisonnable.

Pour finir quelques indicateurs sur ce qui a été développé :

2 fonctionnalités développés (certes simples mais quand même !)

  • nouveauProduitEstInnovant
  • nouveauProduitEstDispoDans6Mois

3 contraintes garanties

  • codeIsNullAlorsViolationContrainte
  • nomIsNullAlorsViolationContrainte
  • nomPlusDe20CaracteresAlorsViolationContrainte

Unicité et comportement dans les collections garanties

  • deuxProduitMemeCodeSontEgaux
  • deuxProduitCodesDifferentsNonEgaux
  • deuxProduitsEgauxOntMemeHashCode
  • deuxProduitsDifferentsOntHashCodeDifferents

Classe ProduitTest

  • 39 lignes de code (dont 4 externalisables)

Classe Produit

  • 18 lignes de codes (hors accesseurs)
  • 1 bloc if
Classe ProduitBuilder réutilisable pour les tests sur modèle, persistance et services

ModelHelper :  2 utilitaires génériques pour le modèle métier

boolean isInstanceOf(final Object obj,Class<? extends Object> objectClass)

boolean equals(final Object notNullObject, final Object other, EqualsBuilder equalsBuilder)


TDD modèle métier : partie 2

Nous avons vu dans la partie 1 de l’article comment écrire les tests unitaires sur la construction de la classe Produit et ses méthodes equals et hashcode.

Je vais insister dans un premier temps sur l’amélioration de l’écriture des setup répétitifs des tests.

Je vais utilisé un builder.

Le besoin est ici de simplifier l’écriture des tests, de plus je souhaite garder un minimum de code dans la classe métier, je n’ai donc pas fait le choix d’écrire une classe ProduitBuilder interne à Produit. Au lieu de cela je me base simplement sur un helper de test. ProduitBuilder est donc ajouté dans le package de test.

public class ProduitBuilder {

	private Produit produit = new Produit();

	private static ProduitBuilder produit() {
		return new ProduitBuilder();
	}

	public ProduitBuilder code(String code) {
		produit.setCode(code);
		return this;
	}

	public static ProduitBuilder produitAyantCode(String code) {
		return produit().code(code);
	}

	public ProduitBuilder nom(String nom) {
		produit.setNom(nom);
		return this;
	}

	public ProduitBuilder innovant(boolean innovant) {
		produit.setInnovant(innovant);
		return this;
	}

	public Produit build() {
		return produit;
	}
}

Voici également le code que j’ai écris dans ProduitTest correspondant au test de la méthode builder de ProduitBuilder

import static com.devagilestories.ProduitBuilder.produitAyantCode;

@Test
public void builder() {
	Produit produit = produitAyantCode("A").nom("builder").innovant(false)
				.build();
	assertEquals("A", produit.getCode());
	assertEquals("builder", produit.getNom());
	assertFalse(produit.isInnovant());
}

Lorsqu’on refactore l’écriture des tests des méthodes equals et hashcode à l’aide de ProduitBuilder cela donne dans ProduitTest :


//...

@Test
public void deuxProduitMemeCodeSontEgaux() {
         assertEquals(
		produitAyantCode("A").nom("produit top").build(),
		produitAyantCode("A").nom("produit super").innovant(false)
			.build());
}

@Test
public void deuxProduitCodesDifferentsNonEgaux() {
	assertFalse(produitAyantCode("A").nom("nom produit A").build()
			.equals(produitAyantCode("B").nom("nom produit A").build()));
}

@Test
public void equalsAuxLimites() {
	// setup
	Produit produit = new Produit();
	// pre-assert
	assertTrue(new Produit() {} instanceof Produit);
	// test&assert
	assertTrue(produit.equalsAuxLimites(produit));
	assertFalse(produit.equalsAuxLimites(null));
	assertFalse(produit.equalsAuxLimites(new Produit(){}));
	assertNull(produitAyantCode("A").build().equalsAuxLimites(
			produitAyantCode("A").build()));
	assertNull(produitAyantCode("A").build().equalsAuxLimites(
			produitAyantCode("B").build()));
}

@Test
public void deuxProduitsEgauxOntMemeHashCode() {
	// respect du contrat general methode hashcode
	// pre-assert
	assertEquals(produitAyantCode("A").build(), produitAyantCode("A")
			.build());
	assertFalse(produitAyantCode("A").build() == produitAyantCode("A")
			.build());

	// assert
	assertTrue(produitAyantCode("A").build().hashCode() 
                          == produitAyantCode("A").build().hashCode());
}

@Test
public void deuxProduitsDifferentsOntHashCodeDifferents() {
	// pour des raisons d'optimisation
	assertFalse(produitAyantCode("A").build().hashCode() 
                           == produitAyantCode("B").build().hashCode());
}

//...

Pour compléter la deuxième partie de l’article je vais proposer une manière simple de
gérer les dates en java tout en introduisant une nouvelle règle :
on considère que la date de mise en ligne du produit est par défaut de 6 mois après sa création.

J’ajoute à mes dépendances maven la librairie joda-time désormais assez connue comme une excellente alternative à la gestion des dates en java.

<dependency>
    <groupId>joda-time</groupId>
    <artifactId>joda-time</artifactId>
    <version>1.6.2</version>
</dependency>

J’ajoute le test nouveauProduitEstDispoDans6Mois dans ProduitTest.

@Test
public void nouveauProduitEstDispoDans6Mois() {
	assertEquals(new LocalDate().plusMonths(6),
			new Produit().getDateMiseEnLigne());
}

Je complète la classe Produit.


private static final int DELAI_MOIS_MISE_LIGNE = 6;

private LocalDate dateMiseEnLigne = new LocalDate().plusMonths(DELAI_MOIS_MISE_LIGNE);

public LocalDate getDateMiseEnLigne() {
	return dateMiseEnLigne;
}

public void setDateMiseEnLigne(LocalDate pDateMiseEnLigne) {
	this.dateMiseEnLigne = pDateMiseEnLigne;
}

Voila, avec joda-time l’ajout d’une date comme attribut dans un objet métier c’est trivial.

Dans cette deuxième partie on a donc vue comment prendre du recul pour améliorer la productivité des tests avant d’aller plus loin dans la conception du modèle.

Dans la partie 3 nous verrons l’ajout de contraintes sur le modèle.

TDD modèle métier : partie 1

On va appliquer la méthode TDD pour modéliser une classe d’un domaine métier.

Supposons un domaine de vente d’applications et utilitaires en ligne, la MOA vient d’écrire une story spécifiant en service en ligne, il s’agit de vendre une application mobile innovante.

C’est la page blanche ! Riens n’est encore écris !

Suite à la lecture de la story l’équipe MOE extrait une première classe candidate « produit » identifiée par un code et un nom, une date de mise en ligne dans 6 mois par défaut, de plus le produit est innovant par défaut.

L’article va montrer comment on peut pousser la démarche TDD afin de construire un harnais de test dès le coeur de la modélisation afin d’obtenir un modèle robuste et fiable, indépendamment des services métier et de la couche de persistance.

On pourrais bien sur considérer que coder la classe produit dans un langage comme java est extrêmement trivial et qu’il est non productif de consacrer plus de 5 minutes à cette tâche. Partant de cette idée l’article va tenter de démontrer que le soin apporté aux modèle va en fin de compte non seulement pérenniser la fiabilité des développements à venir mais aussi encourager la mise en place en amont de pratiques qui vont améliorer globalement la productivité.

La session de travail sera réalisée sous eclipse, java 6 et Junit 4.

Pour augmenter la productivité des tests j’ai installé le plugin eclipse MoreUnit : eclipse update

Deux raccourcis très pratiques proposés par More Unit :

Ctrl+J : naviguer de la classe de test à la classe testée et reciproquement

Ctrl+R : lancer les tests à partir de la classe de test ou bien de la classe testée !

C’est parti !

On commence par la création de la classe de test ProduitTest avec Junit 4

Création du premier test « un nouveau produit est innovant »

@Test
public void nouveauProduitEstInnovant()
    new Produit();
}

Création de la classe Produit avec l’ide pour que le test compile et ajout de l’attribut innovant :

private boolean innovant;

Génération des mutateurs/accesseurs avec le raccourci clavier Alt+Maj+S puis retour au test Ctrl+J pour poser l’assertion :

@Test
public void nouveauProduitEstInnovant() {
    //setup
    Produit produit = new Produit();

    //test & assert
    assertTrue(produit.isInnovant());
}

On exécute notre premier test (Ctrl+R) : c’est rouge ! On corrige la valeur du booléen par défaut :

private boolean innovant = true;

Ctrl+J et Ctrl+R : c’est vert !

En inlinant la variable locale produit c’est plus concis (Alt+Maj+I)

@Test
public void nouveauProduitEstInnovant() {
      //setup & test & assert
      assertTrue(new Produit().isInnovant());
}

Voila, on a écrit notre premier test.

Deuxième test : « deux produits ayant le même code sont égaux »

Classe Produit :

private String code;
//...accesseurs

Classe ProduitTest :

@Test
public void deuxProduitMemeCodeSontEgaux() {
      //setup
      Produit produitA = new Produit();
      produitA.setCode("A");
      Produit produitB = new Produit();
      produitB.setCode("A");

      //test & assert
      assertEquals(produitA,produitB);
}

Ctrl+R : c’est rouge

On génère la méthode equals (Alt+Maj+S) dans la classe Produit en se basant sur l’attribut code :

@Override
public boolean equals(Object obj) {
      if (this == obj)
	return true;
      if (obj == null)
	return false;
      if (getClass() != obj.getClass())
	return false;
      Produit other = (Produit) obj;
      if (code == null) {
	   if (other.code != null)
		return false;
      } else if (!code.equals(other.code))
		return false;
     return true;
}

Le test passe au vert mais la méthode equals est relativement complexe et peu lisible si l’on considère que l’on veut juste exprimer que le code est l’identifiant de la classe Produit.

On va améliorer le code en utilisant la classe EqualsBuilder de la librairie apache commons-lang qui garantie la fiabilité des comparaisons des attributs :

@Override
public boolean equals(Object obj) {
	if (this == obj)
	   return true;
	if (obj == null)
	   return false;
	if (getClass() != obj.getClass())
	   return false;
	Produit other = (Produit) obj;
	return new EqualsBuilder().append(code, other.code).isEquals();
}

On ajoute un test  pour le comportement de equals aux limites :

@Test
public void equalsAuxLimites() {
    //setup
    Produit produit = new Produit();
    //pre-assert
    assertTrue(new Produit(){} instanceof Produit);
    //test&assert
    assertTrue(produit.equals(produit));
    assertFalse(produit.equals(null));
    assertFalse(produit.equals(new Produit(){}));
}

Idée de refactoring de la méthode equals : c’est le même comportement aux limites dans le cas général pour tous les objets métiers.

@Override
public boolean equals(Object obj) {
    Boolean isEquals = equalsAuxLimites(obj);
    if (isEquals != null)
	return isEquals;
    else {
	Produit other = (Produit) obj;
	return new EqualsBuilder().append(code, other.code).isEquals();
    }
}

//TODO : a externaliser
public Boolean equalsAuxLimites(Object obj) {
    if (this == obj)
	return true;
    else if (obj == null)
	return false;
    else if (getClass() != obj.getClass())
	return false;
    else
	return null;
}

Autre test portant sur l’identifiant code : « deux produits de code différents ne sont pas égaux »

@Test
public void deuxProduitCodesDifferentsNonEgaux() {
    Produit produitA = new Produit();
    produitA.setCode("A");
    Produit produitB = new Produit();
    produitB.setCode("B");

    assertFalse(produitA.equals(produitB));
}

De même que pour la méthode equals on améliore l’écriture de la méthode hashCode grâce à la classe HashCodeBuilder :

@Override
public int hashCode() {
    return new HashCodeBuilder(ODD_NUMBER, 1).append(code).toHashCode();
}

Afin de respecter le contrat général des méthodes hashcode on ajoute un test sur la méthode hashcode : « deux produits égaux ont le même hashcode »

@Test
public void deuxProduitsEgauxOntMemeHashCode() {
   // setup
   Produit produitA = new Produit();
   produitA.setCode("A");
   Produit produitABis = new Produit();
   produitABis.setCode("A");

   // pre-assert
   assertEquals(produitA, produitABis);
   assertFalse(produitA == produitABis);

   // assert
   assertTrue(produitA.hashCode() == produitABis.hashCode());
}

On ajoute un autre test sur la méthode hashCode pour garantir l’utilisation optimale du produit dans les maps : « deux produits différents ont un hashcode différent » :

@Test
public void deuxProduitsDifferentsOntHashCodeDifferents() {
     //setup
     Produit produitA = new Produit();
     produitA.setCode("A");
     Produit produitB = new Produit();
     produitB.setCode("B");

     //assert
     assertFalse(produitA.hashCode() == produitB.hashCode());
}

On ajoute ensuite l’attribut nom et ses accesseurs dans la classe Produit et on enrichit les tests de la méthode equals en renseignant le nom du produit.

@Test
public void deuxProduitMemeCodeSontEgaux() {
	Produit produitA = new Produit();
        produitA.setCode("A");
        produitA.setNom("produit top");
        Produit produitAbis = new Produit();
        produitAbis.setCode("A");
        produitAbis.setNom("produit super");
        produitAbis.setInnovant(false);
       assertEquals(produitA, produitAbis);
}

@Test
public void deuxProduitCodesDifferentsNonEgaux() {
        Produit produitA = new Produit();
        produitA.setCode("A");
        produitA.setNom("nom produit A");
        Produit produitB = new Produit();
        produitB.setCode("B");
        produitB.setNom("nom produit A");
        assertFalse(produitA.equals(produitB));
}

Nous venons de mettre en place avec TDD les tests essentiels d’une classe du modèle à savoir sa construction et son comportement dans les méthodes equals et hashCode. On a également vu comment quelques idées simples de refactoring permettent de simplifier le code et la testabilité de la classe métier.

Dans la partie 2 de l’article on complétera le modèle et on verra comment améliorer les setup répétitifs des tests…

Le DDD : domain driven design

Le DDD est issu du savoir-faire d’Eric Evans dans le domaine de la conception de logiciels. La capture de ces concepts à donner lieu à la publication du livre éponyme en 2004.

Le DDD n’est pas une méthode de modélisation mais plutôt une façon de pensée ou philosophie de conception. Même si la pratique de DDD se marie particulièrement bien à un cycle de développement agile via l’extreme programming (XP) et en particulier à TDD cette association ne constitue cependant nullement un pré-requis obligatoire.

Le propos de cette pratique est de focaliser la conception sur le domaine métier et de produire un modèle métier expressif. Un point important de cette philosophie est d’encourager la communication entre les experts métiers et les développeurs afin de partager un langage commun. Le modèle pourra être affiné et refactoré au fur et à mesure des échanges entre les équipes métiers et les équipes de réalisation.

Il faut signaler la différence de philosophie avec la démarche MDA (Model Driven Architecture) ou il est aussi question de modèle, cependant la comparaison s’arrête là, en effet le MDA repose sur des outils de transformation et de génération de code alors que le DDD parle de pratiques de conception et n’impose aucun outil.

Afin de rendre concret le propos voici un exemple pratiqué réel de cycle de développement d’un modèle métier qui s’inspire de DDD.

Le modèle se construit au cour d’un cycle itératif, chaque itération étant fixée à 2 semaines. En début d’itération la capture du besoin se fait entre les experts métier et l’ensemble des développeurs, cette capture des concepts du domaine se fait au travers de « stories » : spécifications concises décrivant les fonctionnalités du domaine. Chaque itération est donc l’occasion de fournir la matière première pour alimenter la mise en oeuvre du domaine. Un aspect important est que cette matière n’est pas livrée d’un bloc aux équipes de réalisation mais au fil des itérations et selon les priorités décidées par les experts métier.

La seconde étape consiste à extraire les nouveaux éléments candidats à la structuration du modèle mais aussi ceux qui peuvent le remettre en cause. A ce stade pas besoin d’outil particulier, un tableau blanc est amplement suffisant. La communication à l’intérieur de l’équipe de réalisation est primordiale, la confrontation des idées garantie la justesse du modèle.

L’implémentation du modèle avec TDD a permis de spécifier finement le coeur du métier, on met ainsi en place un harnais de tests unitaires qui garanti la solidité du modèle quelque soit son utilisation. Le soin apporté à l’écriture de ces tests permet de décrire le modèle à la manière d’une documentation. L’intérêt évident ici est de passer plus de temps à soigner des tests qui colleront de fait toujours à la réalité plutôt que d’entretenir une documentation de conception technique très (trop ?) détaillée et dont la cohérence doit être vérifiée manuellement.

L’implémentation du modèle selon DDD ne consiste pas seulement à coder un modèle anémique avec seulement ses attributs, les règles concernant le coeur du métier sont régulièrement factorisées en poussant  des couches processus / services métier vers la couche modèle, ce qui soit dit en passant simplifie grandement l’écriture des tests.

Le pratique de DDD vient avec certains pattern de conception visant à simplifier les couches et l’utilisation du modèle (repository, builder, helper de test,…), le sujet méritant un article à lui tout seul, je m’efforcerai d’y revenir, ce sera aussi l’occasion de parler des framework que l’on qualifie de « framework de productivité ».

Se pose ensuite la question de « démontrer » un modèle non persistant aux utilisateurs ou tout du moins à ceux qui les représentent. La solution est d’automatiser les stories / spécifications fonctionnelles afin de vérifier au travers d’assertions que le comportement du modèle est bien celui attendu (je ne détaille pas ici les outils, cela pourra faire l’objet d’un article). On peut donc poursuivre une conception du domaine constructive sans pour autant être bloquer par une phase de définition d’architecture technique et de choix de frameworks. Lorsque le domaine est suffisamment étoffé on peut alors envisager de commencer à le persister et de le présenter via un framework web.

La communication entre experts métier et développeurs se fait bien sur pendant la capture du besoin, cependant un autre aspect est extrêmement important : lorsqu’un modèle dérive ou génère des approximations par rapport à la réalité et au vocabulaire métier on obtient généralement du code qu’il faudra refaire et dont la compréhension diminue au fur et à mesure que l’on avance. Sur ce point là, le levier sur la productivité est donc très élevé. Les revues de modèle régulières entre tous les acteurs ont permis de minimiser la distorsion, le modèle est présenté aux experts métier sous forme de diagrammes de classes UML. Les risques d’incompréhension liés au formalisme UML doivent être minimisés sous forme d’échanges qui favorisent le jargon métier par rapport à l’explication des concepts objets sous jacents.

Dernier point, les diagrammes de classes sont obtenus par rétro conception manuelle, un simple outil de présentation tel que Visio est suffisant. Le développement itératif et les revus de modèle minimisent les risques de perte d’information, la pratique a démontré l’efficacité de la démarche.

L’expérience DDD a été pour moi réellement concluante pour construire un modèle métier riche et sans ambiguités. Mais étant donné que DDD n’impose rien en terme de méthodologie et d’outils, qu’est ce qui fait la différence par rapport aux autres techniques ? Il y a bien sur la capture de patterns spécifiques aux couches métier mais rien n’empêche de les retrouver dans des phases de conception plus classique. L’essentiel à mon avis est lié à la proximité entre le garant du métier et celui qui développe.

La méthode TDD : retour d’expérience

Quelques rappels historiques :

  • Kent Beck est l’inventeur du TDD (Test Driven Development)
  • En 1999 il lance officiellement la méthode Extreme Programming (XP) dont il est co-inventeur
  • En 2001 il fait partie des 17 signataire du manifeste agile.

Les principes généraux du TDD :

  • on a au départ un certain niveau de qualité de code
  • on écrit le test tout en générant le squelette du code testé à l’aide de l’ide
  • on vérifie que le test échoue
  • on complète le code jusqu’à ce que le test passe
  • on refactore le code pour maintenir le niveau de qualité (on peut même faire mieux!) que l’on avait avant d’écrire le test

J’ai vécu plusieurs étapes avant d’utiliser TDD :

Premier étape : pas de tests automatisés avec peu de refactoring : le logiciel est exploitable à court terme mais la qualité et la maintenabilité ne font que diminuer, les évolutions sont de plus en plus coûteuses et de moins en moins de développeurs sont motivés pour faire évoluer le logiciel

Deuxième étape : Pas de tests automatisés mais beaucoup de refactoring : le logiciel est exploitable à moyen terme et colle bien au besoin, la qualité est correcte bien que non homogène mais cela dépend d’une connaissance exhaustive du code, de beaucoup de courage des équipes, les régressions sont inévitables, la confiance des nouveaux arrivants est faible pour bouger le code, le turn-over des équipes est très pénalisant.

Troisième étape : une conception technique documentée sous forme de spécifications techniques détaillées (STD) formalisée en UML avant de coder et des tests écrit après avoir coder : le logiciel est exploitable, robuste, de bonne qualité, l’évolutivité est correcte compte tenu de la couverture des tests mais les délais augmentent à cause de la phase de STD, les STD sont très difficiles à maintenir, le code ne fait que s’éloigner des STD, les équipes sont peu motivés pour écrire des tests après le code, les itérations rapides ne sont pas privilégiés.

Et enfin : le TDD avec beaucoup de refactoring : le logiciel est tout le temps exploitable, flexible, la qualité est constante, les livraisons rapides, le niveau de confiance élevé grâce au harnais de test, le turn-over est supportable à condition de ne pas changer toute l’équipe d’un seul coup, mais il faut aimer écrire du code, être courageux et une réelle volonté de ne « rien lâcher », les valeurs humaines font la différence et c’est finalement assez difficile à prévoir.

En conclusion,  j’aime bien écrire du code sans « sur design » et avec un minimum de dette technique, le TDD m’a donc logiquement réussi et constitue une amélioration par rapport à mes expériences précédentes. Cela dit la route est encore longue et l’innovation toujours nécessaire afin de rendre l’écriture de code toujours moins fastidieuse et toujours plus proche de l’expression du besoin.

 

Un nouveau blog

Voila donc un nouveau blog destiné à ceux qui s’intéressent au développement de logiciels basé sur la plateforme java et les autres langages du web sur fond de méthodes agiles. Pas de planification pour le moment sur les articles à venir mais déjà quelques idées et motivations à partager.

La motivation est de proposer des histoires qui permettent d’écrire du code proche du besoin, mettre en place des architectures efficaces et délivrer en continue. En un mot la promotion pour le rapprochement entre le développeur et celui qui utilise le logiciel.

Quelques idées :

  • retours d’expériences concret avec des exemples de code
  • billets pas toujours sur la technique mais aussi le ressenti
  • pas forcément uniquement du « java EE » mais aussi d’autres langages comme javascript ou groovy par exemple

A bientôt sur ce blog