Spring-Batch (Part I) : Tester unitairement & mocker simplement. FlatFileItemReader

 

 

 

 

Comment tester unitairement et simplement les trois composants principaux de Spring-Batch: Reader, Processor, Writer?

Plus précisément comment mocker le(s) contexte(s) spring-batch pour ces trois composants?

Ce billet, sur plusieurs parties, présente quelques démos pratiques permettant de réaliser ce genre de tests unitaires (et d'intégration en prime).

Les démos sont réalisées en combinant le duo de spring: spring-batch et spring-boot.
Ce duo simplifie grandement (voire à outrance) la configuration de spring et laisse en arrière-plan beaucoup trop de complexité.

La première démo permet de réaliser un projet spring-batch avec un job contenant un seul step, le tout configuré avec java (no xml).

L'unique (step du) job consiste à charger en base (BD) les lignes d'un fichier csv nommé entreprises.csv.

Nul besoin à ce stade de processor!

Ainsi, notre job contient uniquement les composants nécessaires: un reader et un writer.

A la fin de cette première partie, nous écrirons un test TU Junit du reader.

Le seul intérêt de tester unitairement le(s) reader(s) est lorsque le métier exige un traitement personnalisé ce qui est généralement le cas! 🙁

Une fois le test TU posé et automatisé, le(s) refactoring(s) et les évolutions (agilité oblige) deviennent des opérations plus sûres.

DÉMO1: projet maven spring-batch/spring-boot

 

Partons donc d'un projet spring-boot créé depuis la page initilzr en ayant sélectionné spring-batch, h2 et lombok.

 

Le pom projet

A toute fin utile, voici le pom du projet:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>fr.netapsys.abdou</groupId>
	<artifactId>demoTestReader</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<packaging>jar</packaging>

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

	<properties>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
		<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
		<java.version>1.8</java.version>
	</properties>

	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-batch</artifactId>
		</dependency>
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>false</optional>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        </dependency>
		<dependency>
			<groupId>org.springframework.batch</groupId>
			<artifactId>spring-batch-test</artifactId>
		</dependency>
	</dependencies>

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

Importons alors ce projet sous l'IDE eclipse ou (mon préféré) STS de spring.

 

Classe modèle

Démarrons le code avec cet unique POJO simple, Entreprise.java, du modèle:

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@AllArgsConstructor
@NoArgsConstructor
@Getter @Setter
@ToString
public class Entreprise {
	private String id;	

        private String rs;
	
        private String dateCreation;
}

 

Bon nombre d'annotations lombok sont présentes pour éviter le code de génération des getters/setters, toString et de tous les constructeurs.

 

Déclaration de notre propre reader

Enchaînons avec le code de notre propre reader nommé MyFlatFileItemReader:

@AllArgsConstructor
@NoArgsConstructor
@Component
public class MyFlatFileItemReader<T> implements ItemReader<T>, ItemStream {

	private @Setter @Getter FlatFileItemReader<T> delegate;
	@Override
	public T read() throws Exception{
		return delegate.read();
	}
	@Override
	public void open(ExecutionContext executionContext) throws ItemStreamException {
		delegate.open(executionContext);
	}
	@Override
	public void update(ExecutionContext executionContext) throws ItemStreamException {
		delegate.update(executionContext);
	}
	@Override
	public void close() throws ItemStreamException {
		delegate.close();
	}
}

 

Vous pourriez le constater, afin de ne pas réinventer la roue, nous nous sommes appuyés sur l'implémentation FlatFileItemReader.

D'où la déclaration de l'attribut delegate. Au passage, nous avons appliqué le design pattern delegate.

La classe FlatFileItemReader de spring-batch permet de lire un fichier plat sur le disque.

Le code précédent est à compléter avec ces entêtes d'import:

import org.springframework.batch.item.ExecutionContext;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemStream;
import org.springframework.batch.item.ItemStreamException;
import org.springframework.batch.item.file.FlatFileItemReader;
import org.springframework.stereotype.Component;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

Configuration du reader de spring

 

Passons donc à la configuration de FlatFileItemReader de spring réalisée via cette méthode statique ci-dessous (utilisée dans les tests):

public static FlatFileItemReader<Entreprise> configureFtaFileReader(final String nameFileCsv,final String[] names) {
		FlatFileItemReader<Entreprise>  reader = new FlatFileItemReader<>();
		reader.setResource(new ClassPathResource(nameFileCsv));
		DelimitedLineTokenizer tokenizer = new DelimitedLineTokenizer();
        tokenizer.setNames(names);
        DefaultLineMapper<Entreprise> defLineMapper = new DefaultLineMapper<Entreprise>();
        defLineMapper.setLineTokenizer(tokenizer);
        BeanWrapperFieldSetMapper<Entreprise> beanWrapFS = new BeanWrapperFieldSetMapper<Entreprise>();
        beanWrapFS.setTargetType(Entreprise.class);
        defLineMapper.setFieldSetMapper(beanWrapFS);   
        reader.setLineMapper(defLineMapper);
        reader.setLinesToSkip(1);
		return reader;
	}

 

Sauvegardons cette méthode statique dans ReaderUtils.java.

Les détails de ce code n'est pas le sujet de ce blog mais retenons ces points:

  • la sortie de la méthode read() est un objet de type Entreprise,
  • le reader est configuré de manière à ignorer la première ligne du fichier (skip le header).

 

Sachez aussi que le contenu du fichier src/main/resources/entreprises.csv est:

id, rs, dateCreation
"1", "rs",2017-05-12
"2",,2017-05-23

Ainsi, seules deux dernières lignes seront chargées en BD puisque le header sera skippé.

PS. Notez également que la seconde ligne ne fournit pas de raisonSociale (rs à vide).

 

Configuration java de spring (zéro xml)

 

La classe centralisée de configuration java de spring-batch est consignée dans AppBatchConfig.java:

package xxxxxxxxxxx;

import .....;

@Configuration
@EnableBatchProcessing
public class AppBatchConfig {
	public static final String[] NAMES_COLUMNS = new String[] { "id", "rs", "dateCreation" };
	@Autowired
    public JobBuilderFactory jobBuilderFactory;
    @Autowired
    public StepBuilderFactory stepBuilderFactory;
	@Autowired
    public DataSource dataSource;
	@Bean
    public FlatFileItemReader<Entreprise> reader() {
		return ReaderUtils.configureFtaFileReader("entreprises.csv", NAMES_COLUMNS);
    }
	@Bean
    public MyFlatFileItemReader<Entreprise> myReader() {
		return new MyFlatFileItemReader<Entreprise>(reader());
    }
	@Bean //**here Datasource  used***//
    public JdbcBatchItemWriter<Entreprise> writer() {
        JdbcBatchItemWriter<Entreprise> writer = new JdbcBatchItemWriter<>();
        writer.setItemSqlParameterSourceProvider(new BeanPropertyItemSqlParameterSourceProvider<Entreprise>());
        writer.setSql("INSERT INTO entreprise (rs, id,dateCreation) VALUES (:rs, :id, :dateCreation)");
        writer.setDataSource(dataSource);
        return writer;
    }
	
	//define job step
	@Bean
    public Job loadDataJob(JobCompletionNotificationListener listener) {
        return jobBuilderFactory.get("myjob")
                .incrementer(new RunIdIncrementer())
                .listener(listener)
                .flow(mystep())
                .end()
                .build();
    }
    @Bean
    public Step mystep() {
        return stepBuilderFactory.get("mystep")
                .<Entreprise, Entreprise> chunk(10)
                //.reader(reader())
                .reader(myReader())
                .writer(writer())
                .build();
    }
}

 

C'est bien cette classe, annotée avec @Configuration et @EnableBatchProcessing, qui centralise la config de spring-batch.

Quelques points méritent votre attention. Nous avons déclaré:

  • un bean myReader (de type MyFlatFileItemReader) annoté avec @Bean,
  • le bean myReader est construit justement avec un delegate de type FlatFileItemReader,
  • un writer de typeJdbcBatchItemWriter lié à la dataSource et une requête sql d'insert,
  • un job puis un step. Le job est branché à un listener permettant de logguer post job réussi les infos de la base.

Récapitulons ce qui est fait dans cette classe java de config. Nous avons configuré (et/ou injecté):

 

  • jobBuilderFactory,
  • stepBuilderFactory,
  • la dataSource,
  • l'unique job nommé myjob,
  • l'unique step nommé mystep,
  • le reader,
  • le writer,
  • le listener (optionnel mais utile pour contrôler les données chargées en base h2 en mémoire).

 

N'oubliez pas de compléter le code avec l'entête des imports (après avoir remplacé les xxx selon votre contexte) comme suit:

import javax.sql.DataSource;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.JobBuilderFactory;
import org.springframework.batch.core.configuration.annotation.StepBuilderFactory;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.item.database.BeanPropertyItemSqlParameterSourceProvider;
import org.springframework.batch.item.database.JdbcBatchItemWriter;
import org.springframework.batch.item.file.FlatFileItemReader;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import xxxxxxx.model.Entreprise;
import xxxxxxx.reader.MyFlatFileItemReader;

import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing;

 

NOTE. La classe générée initialement contenant l'annotation @SpringBootApplication reste inchangée.

Enfin, la classe [optionnelle] listener attachée au job est:

package xxxxxxxxxxxxxxx;
@Slf4j
@Component
public class JobCompleteListener extends JobExecutionListenerSupport {

        @Autowired
	private JdbcTemplate jdbcTemplate;

	@Override
	public void afterJob(JobExecution jobExecution) {
		if(jobExecution.getStatus() == BatchStatus.COMPLETED) {
			final RowMapper<Entreprise> rowMapper = (rs, row) -> new Entreprise(rs.getString("id"), rs.getString("rs"),  rs.getString("dateCreation"));
	List<Entreprise> ents = jdbcTemplate.query("SELECT id, rs, dateCreation FROM entreprise",rowMapper );
	log.info("Found <" + ents + "> in DB ");
	}else{
		log.error("\tJOB NOT FINISHED CORRECTLY");
	}
}
}

 

Et les imports utiles (à adapter):

import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.listener.JobExecutionListenerSupport;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Component;
import fr.netapsys.abdou.model.Entreprise;
import lombok.extern.slf4j.Slf4j;

 

Résumons, nous avons pour l'instant six classes dans notre démo1:

  • une classe java générée par spring.io.initilzr qui reste inchangée (dans mon cas la classe s'appelle DemoReaderApp.java),
  • une classe modèle nommée Entreprise contenant trois attributs simples (String) annotée avec lombok,
  • une classe ReaderUtils qui sert à configurer correctement le FlatFileItemReader de spring,
  • une classe pour définir notre propre reader, nommée MyFlatFileItemReader, construite à partir de FlatFileItemReader,
  • une AppBatchConfig qui centralise la config java de spring (zéro xml config):  job, step, reader, writer,
  • une classe optionnelle du listener.

 

Pour être complet, avant de lancer la cmd mvn spring-boot:run rajouter le fichier src/main/resources/schema-all.sql qui contient ce sql:

DROP TABLE entreprise IF EXISTS;

CREATE TABLE entreprise  (
    id VARCHAR(20) NOT NULL PRIMARY KEY,
    rs VARCHAR(20),
    dateCreation VARCHAR(20)
);

 

Noter néanmoins que pour le test TU Junit ci-après nous n'avions besoin que des readers xxxFlatFileItemReader.

 

TEST UNITAIRE JUNIT

 

Nous arrivons à l'essentiel de notre billet. Écrivons un test unitaire pour tester MyFlatFileItemReader notre propre reader:


public class TUReaderTest {
	@Test
	public void testTUMyReader() throws Exception{
		FlatFileItemReader<Entreprise> delegate = ReaderUtils.configureFtaFileReader("entreprises.csv",AppBatchConfig.NAMES_COLUMNS);
	//delegate.open(MetaDataInstanceFactory.createStepExecution("theStep",1L).getExecutionContext());				MyFlatFileItemReader<Entreprise> myReader= new MyFlatFileItemReader<>(delegate);
		CommonTestUtils.assertEntreprisesReadden(myReader);
	}
}

 

Avec les imports (à adapter):

import org.junit.Test;
import org.springframework.batch.item.file.FlatFileItemReader;
import org.springframework.batch.test.MetaDataInstanceFactory;

import xxxx.model.Entreprise;
import xxx.reader.MyFlatFileItemReader;

 

La méthode assertEntreprisesReadden est justement une méthode statique simple d'assertion:

import static org.junit.Assert.*;
import org.springframework.batch.item.ItemReader;
import xxxx.model.Entreprise;

public class CommonTestUtils {
	public static void assertEntreprisesReadden(ItemReader<Entreprise> reader) throws Exception {
		int k=1;
		Entreprise entr = reader.read()  ;
		while( entr!=null  ){
			assertNotNull(entr);
			assertEquals(""+k,entr.getId());
			entr = reader.read();
			k++;
		}
	}
}

 

Si vous exécutez ce test Junit ( via mvn test ou clic-droit->run->Junit test ) vous aurez cette erreur:

ReaderNotOpenException: reader must be open...

 

Pour résoudre ce problème, dé-commentez la ligne n° 6 ci-dessus laissée en commentaire, puis relancer le test.

Le test passe au vert et les assertions, dans la méthode 'assertEntreprisesReadden', sont validées.

 

Question: Que contient donc notre test unitaire d'intéressant?

La force de spring est de rendre les choses simples encore plus simples et les choses complexes deviennent accessibles!

Comment?

La ligne de code delegate.open permet de simuler un contexte spring-batch.

Plus précis, la méthode MetaDataInstanceFactory.createStepExecution("theStep",1L).getExecutionContext() permet de mocker le contexte d'exécution et ainsi d'ouvrir le flux du reader.

La classe MetaDataInstanceFactory est fournie par spring-batch-test.

 

Vous constatez que le test TU est un test unitaire sans aucun runner junit.

Aucune config (ni java ni xml) de spring n'est nécessaire.

C'est bien un test unitaire du reader seulement.

A noter par exemple que le listener attaché au job n'opère pas ici!

 

Ok! Sauf que, vous l'avez certes remarqué, jusqu'ici notre propre reader, MyFlatFileItemReader, ne fait rien de plus que le FlatFileItemReader!

Dans la prochaine partie, nous rajoutons, dans la méthode read de myReader, un traitement particulier spécifique pour toute entreprise:

Si la déclaration de sa raison sociale est manquante ou vide, nous lui affecterons une valeur (calculée depuis un référentiel national par exemple).

A bientôt dans la seconde partie qui doit arriver lundi prochain :-).

 

4 commentaires

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Captcha *