Initializing DB data in Spring Boot for different environments

In many scenarios, it is desirable to create seeded data for an environment prior to usage. In this post, we will discuss how to initialize (running startup scripts) a NoSQL DB in different environments with seeded data, by the facilities provided in Spring Boot.

From Spring Initializr create a project with at least “Web” and “Cassandra” dependencies. Your pom.xml will look something like

<?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>
   <parent>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-parent</artifactId>
      <version>2.1.1.RELEASE</version>
      <relativePath/> <!-- lookup parent from repository -->
   </parent>
   <groupId>com.blog</groupId>
   <artifactId>musicstore</artifactId>
   <version>0.0.1-SNAPSHOT</version>
   <name>musicstore</name>
   <description>Demo project for Spring Boot</description>

   <properties>
      <java.version>1.8</java.version>
   </properties>

   <dependencies>
      <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-actuator</artifactId>
      </dependency>
      <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-data-cassandra</artifactId>
      </dependency>
      <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-web</artifactId>
      </dependency>

      <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-test</artifactId>
         <scope>test</scope>
      </dependency>
   </dependencies>

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

</project>

Once you have your project template, begin to create the web services, models, and configuration for the application. The project layout may look something like below:

Spring boot project structure

Assuming your NoSQL database is up, you can test out the application by adding the Spring DB configuration in the application.properties file (see a full list here) and running the app. *Note* At this point there is no need for adding a configuration class and creating bean(s) for the NoSQL db — Spring boot uses the key-value pairs in the configuration to “auto” create the DB bean(s).
Here’s the output from running:

2019-01-23 19:15:58.816 ERROR 17905 --- [           main] o.s.boot.SpringApplication               : Application run failed

org.springframework.context.ApplicationContextException: Unable to start web server; nested exception is org.springframework.boot.web.server.WebServerException: Unable to start embedded Tomcat
....
....
Caused by: com.datastax.driver.core.exceptions.InvalidQueryException: Keyspace 'music_store' does not exist

This exception is a Cassandra DB specific error relating to … the  keyspace not existing! By default, our keyspace (think of this as our DB) can not be created using the convenient Spring Cassandra properties (in our application.properties). We will now need to create our Cassandra DB beans manually and tell it to create a keyspace if it does not exist.
That configuration file looks like this

package com.blog.musicstore.configuration;

import com.blog.musicstore.MusicStoreApplication;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.cassandra.config.AbstractCassandraConfiguration;
import org.springframework.data.cassandra.config.CassandraCqlClusterFactoryBean;
import org.springframework.data.cassandra.config.SchemaAction;
import org.springframework.data.cassandra.core.cql.keyspace.CreateKeyspaceSpecification;
import org.springframework.data.cassandra.core.cql.keyspace.KeyspaceOption;
import org.springframework.data.cassandra.repository.config.EnableCassandraRepositories;

import java.util.*;

@Configuration
@EnableCassandraRepositories(basePackages = "com.blog.musicstore")
public class DatabaseConfig extends AbstractCassandraConfiguration {

    @Value("${spring.data.cassandra.keyspace-name}")
    private String keyspaceName;

    @Value("${spring.data.cassandra.port}")
    private Integer port;

    @Value("${spring.data.cassandra.contact-points}")
    private String contactPoints;

    @Bean
    @Override
    public CassandraCqlClusterFactoryBean cluster() {
        CassandraCqlClusterFactoryBean bean = new CassandraCqlClusterFactoryBean();
        bean.setKeyspaceCreations(getKeyspaceCreations());
        bean.setContactPoints(contactPoints);
        bean.setPort(port);
        bean.setJmxReportingEnabled(false);
        return bean;
    }

    @Override
    protected String getKeyspaceName() {
        return keyspaceName;
    }

    @Override
    public String[] getEntityBasePackages() {
        return new String[]{MusicStoreApplication.class.getPackage().getName()};
    }

    @Override
    protected List<CreateKeyspaceSpecification> getKeyspaceCreations() {
        return Collections.singletonList(CreateKeyspaceSpecification.createKeyspace(getKeyspaceName())
                .ifNotExists()
                .with(KeyspaceOption.DURABLE_WRITES, true)
                .withSimpleReplication());
    }

    @Override
    public SchemaAction getSchemaAction() {
        return SchemaAction.CREATE_IF_NOT_EXISTS;
    }
}

Compiling and running the app again, we no longer see errors. The Keyspace was also created and so were the tables (Model repository classes should extend CassandraRepository and have @Repository annotation). That’s great!

Keyspace created with tables

Adding data for different environments

Spring uses Profiles to allow runtime differentiation of the same compiled bytecode. We will use it to perform specific db seeding prior to running the application. This can be used to ensure different environment(s) have different setup data prior (often for testing).
In Spring, it’s very easy. Just add a property file for each environment/profile.

application.properties

spring.data.cassandra.keyspace-name=music_store
spring.data.cassandra.contact-points=localhost
spring.data.cassandra.port=9042

application-dev.properties

spring.data.cassandra.keyspace-name=musicstore_dev
spring.data.cassandra.contact-points=localhost
spring.data.cassandra.port=9042
not.a.real.property.dev.specific.stuff=blah

application-qa.properties

spring.data.cassandra.keyspace-name=musicstore_qa
spring.data.cassandra.contact-points=localhost
spring.data.cassandra.port=9042
not.a.real.property.qa.specific.stuff=blah

Now when we run our application make sure to set the active profile to the correct target — Specify via env variable, JVM system properties, context param on web app (xml), etc.

-Dspring.profiles.active=dev

You should now see the new Keyspace set from the profile properties, created in Cassandra. But there is no data in it…

To add data in the DB, it would be nice if our there is a spring property to set in the properties file that specifies a script to run after keyspace creation. This capability is provided if using JPA or Hibernate. If we look into the “AbstractCassandraConfiguration” interface (used for creating our Database beans)  there is a method specified for adding data and tearing down:

getStartupScripts

and

getShutdownScripts

respectively. So let’s set up a startup script to load our SQL for each env. (Note: It would be better to use an ORM library to interface with our DB, rather than using SQL or CQL).

SQL startup seed script

DatabaseConfig.java

package com.blog.musicstore.configuration;

import com.blog.musicstore.MusicStoreApplication;
import io.micrometer.core.instrument.util.IOUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.Resource;
import org.springframework.data.cassandra.config.AbstractCassandraConfiguration;
import org.springframework.data.cassandra.config.CassandraCqlClusterFactoryBean;
import org.springframework.data.cassandra.config.SchemaAction;
import org.springframework.data.cassandra.core.cql.keyspace.CreateKeyspaceSpecification;
import org.springframework.data.cassandra.core.cql.keyspace.KeyspaceOption;
import org.springframework.data.cassandra.repository.config.EnableCassandraRepositories;

import java.io.IOException;
import java.io.InputStream;
import java.util.*;

@Profile({"qa", "dev"})
@Configuration
@EnableCassandraRepositories(basePackages = "com.blog.musicstore")
public class DatabaseConfig extends AbstractCassandraConfiguration {

    @Value("${spring.data.cassandra.keyspace-name}")
    private String keyspaceName;

    @Value("${spring.data.cassandra.port}")
    private Integer port;

    @Value("${spring.data.cassandra.contact-points}")
    private String contactPoints;

    @Value("classpath:env/sql/seed-#{environment.getActiveProfiles()[0]}.sql")
    private Resource sqlImport;

    //@Lazy
    //@Autowired
    //CassandraAdminTemplate template;

    @Bean
    @Override
    public CassandraCqlClusterFactoryBean cluster() {
        CassandraCqlClusterFactoryBean bean = new CassandraCqlClusterFactoryBean();
        bean.setKeyspaceCreations(getKeyspaceCreations());
        bean.setContactPoints(contactPoints);
        bean.setPort(port);
        bean.setJmxReportingEnabled(false);
        return bean;
    }

    @Override
    protected String getKeyspaceName() {
        return keyspaceName;
    }

    @Override
    public String[] getEntityBasePackages() {
        return new String[]{MusicStoreApplication.class.getPackage().getName()};
    }

    @Override
    protected List<CreateKeyspaceSpecification> getKeyspaceCreations() {
        return Collections.singletonList(CreateKeyspaceSpecification.createKeyspace(getKeyspaceName())
                .ifNotExists()
                .with(KeyspaceOption.DURABLE_WRITES, true)
                .withSimpleReplication());
    }

    @Override
    protected List<String> getStartupScripts() {
        List<String> scripts = new ArrayList<>();

        try(InputStream is = sqlImport.getInputStream()) {
            String string =  IOUtils.toString(is);
            scripts = Arrays.asList(string.split(";"));
        } catch (IOException e) {
            e.printStackTrace();
        }
        /* Doesn't work
        // 1) no valid reference to cassandraTemplate (Not initialized)
        // 2) can't extract sql from DAO
        try {
            ProductOrder productOrder = new ProductOrder("1","2", "3", new Date());
            template.insert(productOrder);

        } catch (Exception e) {
            e.printStackTrace();
        }
        return new ArrayList<>();
        */

        return scripts;
    }

    @Override
    public SchemaAction getSchemaAction() {
        return SchemaAction.CREATE_IF_NOT_EXISTS;
    }
}

Now with, the updated config, we should see data in our environment created on execution of our application.

Data seeded in environment

And there we have it, a seeded DB for our target environment/profile (Note: Don’t forget to also add scripts for shutdown of the application).

Setting up React Jest and Enzyme

Jest is a popular JS framework for testing React js applications. By itself, it may need additional functionality to test React capabilities. Enzyme is a tool used to facilitate component testing.

In this quick overview, we will setup a React 16 CRA application with Jest & Enzyme using NPM.

Assuming we have our application created from

npx create-react-app my-app

Hop into our app directory “my-app” and notice the “node_modules” folder. By itself, there are many existing capabilities that our provided out of the box, one of which being jest

Node_modules containing jest

 

 

 

 

 

 

So according to the Jest documentation for getting started, we can specify a “test” command for NPM to run jest. By default test will look for in a few places  one of them being the existing “App.test.js” file created from CRA. Lets edit it with a pure Jest test and run “npm test”.

package.json

{
  "name": "test-app",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@material-ui/core": "^3.8.3",
    "react": "^16.7.0",
    "react-dom": "^16.7.0",
    "react-router-dom": "^4.3.1",
    "react-scripts": "2.1.3"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "jest",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": "react-app"
  },
  "babel"
  "browserslist": [
    ">0.2%",
    "not dead",
    "not ie <= 11",
    "not op_mini all"
  ]
}

App.test.js

import React from 'react';

test('renders without crashing', () => {
  expect(1).toBe(1);
});

Running “npm test” we get the error

({"Object.<anonymous>":function(module,exports,require,__dirname,__filename,global,jest){import React from 'react';
^^^^^

SyntaxError: Unexpected identifier

Based on the error, (first line of our test file) it looks like the compiler is complaining about the React and ES6 syntax. Reading more in Jest, getting started, it states that we have should also specify a .babelrc file in order to use ES6 and react features in  Jest.  Lets go ahead and add that file in the CRA root.

.babelrc

{
"presets": ["@babel/env", "@babel/react"]
}

Now if we rerun the test again “npm test” we should see a pass. Great!

Passing Jest test

The test we have at the moment does not test anything specific to React, i.e. component rendering. To get this working we want to use Enzyme — It is not provided from CRA. Here’s the guide.

$ npm install --save-dev enzyme enzyme-adapter-react-16

We also need to add a setup file before using Enzymes features. The following helper file is added in the CRA root folder

enzyme.js

// setup file
import Enzyme, { configure, shallow, mount, render } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

configure({ adapter: new Adapter() });
export { shallow, mount, render };
export default Enzyme;

Back in our App.test.js we can edit the test to check if our js component will render.

import React from 'react';
import App from './App';
import { shallow } from './enzyme';

test('renders without crashing', () => {
	const app = shallow(<App/>);
	expect(app.containsAnyMatchingElements([<a>
        Learn React
      </a>
    ])
  ).toBe(true);
});

Here’s what we see.

Jest encountered an unexpected token

The error is from an import inside our test component “App.js” stating ” Jest encountered an unexpected token”. From the output, it looks like the svg file is being parsed incorrectly. This error has been noted elsewhere.

We start by adding the assetTransformer.js file in the root

assetTransformer.js

const path = require('path');

module.exports = {
  process(src, filename, config, options) {
    return 'module.exports = ' + JSON.stringify(path.basename(filename)) + ';';
  },
};

And allow Jest to perform this transformation on assets during module mapping. This is done by adding an attribute in the “jest” property of package.json

{
  "name": "test-app",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@material-ui/core": "^3.8.3",
    "react": "^16.7.0",
    "react-dom": "^16.7.0",
    "react-router-dom": "^4.3.1",
    "react-scripts": "2.1.3"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "jest",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": "react-app"
  },
  "browserslist": [
    ">0.2%",
    "not dead",
    "not ie <= 11",
    "not op_mini all"
  ],
  "jest": { 
    "moduleNameMapper": { 
      "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/assetTransformer.js", 
      "\\.(css|less)$": "<rootDir>/assetTransformer.js" 
    } 
  },
  "devDependencies": {
    "enzyme": "^3.8.0",
    "enzyme-adapter-react-16": "^1.7.1"
  }
}

Here’s what we get from “npm test” this time:

Passing Jest + Enzyme test

Perfect!

 

 

Services: To mock or not to mock?

When testing an application at the integration level, there are at least 2 paradigms commonly used. Here we will briefly discuss:

  • End-to-End (e2e) testing on a Test environment
  • Isolated testing using mocked services

On an e2e testing environment, services are fully deployed and exist with the proper configuration necessary to talk to the app and/or services. This is what many testers may associate with when testing.

When working with an app using mocked services, this allows us to tailor the behavior we return to the consumer application. This is desirable due to 3rd party availability restrictions, API quotas, a non-stable service, or just lack of control and the negative effect this has on testing the app in an Integrated environment.

So then I should use mocks, right?

It depends! I prefer to use an actual service for running sanity checks and any automated functional tests. If you can create multiple testing environments, the one closest to the Production application should be configured with full non-mocked services. The reasoning is that you want to have as close a mirror to your application as possible with little-to-no gotchas.

If you are a developer of an app you may want to take the mocked services approach. With this, development is not compromised because of reasons out of your immediate control. Mocking services also allows you to better execute a wider array of paths between the app-service contract. Please note: mocking can also be performed in unit tests themselves — and in many cases may negate the need for standing up mock services in your local environment.

Is that it?

Not really. Since there are minimal rules in creating software, you may encounter an environment which is used for e2e testing but with mocked services. I wouldn’t suggest this since you get less of a full picture which is provided when using a truly integrated e2e environment.