package org.jboss.seam.mock;
import static org.jboss.seam.mock.DBUnitSeamTest.Database.HSQL;
import static org.jboss.seam.mock.DBUnitSeamTest.Database.MYSQL;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.sql.Connection;
import java.sql.Types;
import java.util.ArrayList;
import java.util.List;
import javax.sql.DataSource;
import org.dbunit.database.DatabaseConfig;
import org.dbunit.database.DatabaseConnection;
import org.dbunit.database.IDatabaseConnection;
import org.dbunit.dataset.IDataSet;
import org.dbunit.dataset.ReplacementDataSet;
import org.dbunit.dataset.datatype.DataType;
import org.dbunit.dataset.datatype.DataTypeException;
import org.dbunit.dataset.datatype.DefaultDataTypeFactory;
import org.dbunit.dataset.xml.FlatXmlDataSet;
import org.dbunit.operation.DatabaseOperation;
import org.jboss.seam.log.LogProvider;
import org.jboss.seam.log.Logging;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Parameters;
import org.testng.annotations.Optional;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.AfterMethod;
/**
* Utility for integration testing with Seam and DBUnit datasets.
* <p>
* Subclass this class instead of <tt>SeamTest</tt> if you need to insert or clean data in
* your database before and after a test. You need to implement <tt>prepareDBUnitOperations()</tt> and
* add instances of <tt>DataSetOperation</tt>s to the <tt>beforeTestOperations</tt> and
* <tt>afterTestOperations</tt> lists. An example:
* <pre>
* public class MyTest extends DBUnitSeamTest {
*
* protected void prepareDBUnitOperations() {
* beforeTestOperations.add(
* new DataSetOperation("my/datasets/BaseData.xml")
* );
* beforeTestOperations.add(
* new DataSetOperation("my/datasets/AdditionalData.xml", DatabaseOperation.INSERT)
* );
* }
* ... // Various test methods with @Test annotation
* }
* </pre>
* <p>
* Note that <tt>DataSetOperation</tt> defaults to <tt>DatabaseOperation.CLEAN_INSERT</tt> if no
* other operation is specified as a constructor argument. The above example cleans all tables defined
* in <tt>BaseData.xml</tt>, then inserts all rows declared in <tt>BaseData.xml</tt>, then inserts
* all the rows declared in <tt>AdditionalData.xml</tt>. This executes before each test method
* is invoked. If you require extra cleanup after a test method executes, add operations to the
* <tt>afterTestOperations</tt> list.
* </p>
* <p>
* A test class obtains the database connection for loading and cleaning of datasets in one of the following ways:
* </p>
* <dl>
* <li>A TestNG test parameter named <tt>datasourceJndiName</tt> is provided by the TestNG test runner, which
* automatically calls <tt>setDatasourceJndiName()</tt> on the test class before a logical test runs.</li>
* <p/>
* <li>An instance of a test class is created manually and the <tt>setDatasourceJndiName()</tt> method is
* called after creation and before a test runs.</li>
* <p/>
* <li>A subclass overrides the <tt>getConnection()</tt> method and returns a custom database connection.</li>
* <p/>
* </dl>
* <p>
* Binary files can be imported into the database from a binary directory, configured with the TestNG parameter
* <tt>binaryDir</tt> or by calling <tt>setBinaryDir()</tt> before a test runs. The binary directory is a classpath
* reference, e.g. <tt>my/org/test/package/binarydir</tt>. In your DBUnit XML flat dataset, declare the path of your file
* as follows: <tt><MYTABLE MYCOLUMN="[BINARY_DIR]/mytestfile.png"/></tt>
* </p>
* <p>
* Referential integrity checks (foreign keys) will be or have to be disabled on the database connection
* used for DBUnit operations. This makes adding circular references in datasets easier (especially for nullable
* foreign key columns). Referential integrity checks are enabled again after the connection has been used.
* </p>
* <p>
* <b>IMPORTANT: The methods <tt>disableReferentialIntegrity()</tt>,
* <tt>enableReferentialIntegrity()</tt>, and <tt>editConfig()</tt> are implemented for HSQL and MySQL. You need to
* configure the DBMS you are using with the <tt>database</tt> TestNG parameter or by calling <tt>setDatabase()</tt>
* before the the test run. If you want to run unit tests on any other DBMS, you need to override the
* <tt>disableReferentialIntegrity()</tt> and <tt>enableReferentialIntegrity()</tt> methods and implement them
* for your DBMS. Also note that by default, if no <tt>database</tt> TestNG parameter has been set or if the
* <tt>setDatabase()</tt> method has not been called before test runs, HSQL DB will be used as the default.</b>
* </p>
* @author Christian Bauer
*/
public abstract class DBUnitSeamTest extends SeamTest
{
public enum Database
{
HSQL, MYSQL
}
private LogProvider log = Logging.getLogProvider(DBUnitSeamTest.class);
protected String datasourceJndiName;
protected String binaryDir;
protected Database database = HSQL;
protected boolean replaceNull = true;
protected List<DataSetOperation> beforeTestOperations = new ArrayList<DataSetOperation>();
protected List<DataSetOperation> afterTestOperations = new ArrayList<DataSetOperation>();
private boolean prepared = false;
@BeforeClass
@Parameters("datasourceJndiName")
public void setDatasourceJndiName(@Optional String datasourceJndiName)
{
if (datasourceJndiName == null) return;
log.debug("Setting datasource name: " + datasourceJndiName);
this.datasourceJndiName = datasourceJndiName;
}
public String getDatasourceJndiName()
{
return datasourceJndiName;
}
@BeforeClass
@Parameters("binaryDir")
public void setBinaryDir(@Optional String binaryDir)
{
if (binaryDir == null) return;
log.debug("Setting binary directory: " + binaryDir);
this.binaryDir = binaryDir;
}
public String getBinaryDir()
{
return binaryDir;
}
@BeforeClass
@Parameters("database")
public void setDatabase(@Optional String database)
{
if (database == null) return;
log.debug("Setting database: " + database);
this.database = Database.valueOf(database.toUpperCase());
}
// We don't have a getDatabase() getter because subclasses might use a different Enum!
@BeforeClass
@Parameters("replaceNull")
public void setReplaceNull(@Optional Boolean replaceNull)
{
if (replaceNull == null) return;
log.debug("Setting replace null: " + replaceNull);
this.replaceNull = replaceNull;
}
public Boolean isReplaceNull()
{
return replaceNull;
}
@BeforeMethod
public void prepareDataBeforeTest()
{
// This is not pretty but we unfortunately can not have dependencies between @BeforeClass methods.
// This was a basic design mistake and we can't change it now because we need to be backwards
// compatible. We can only "prepare" the datasets once all @BeforeClass have been executed.
if (!prepared) {
log.debug("Before test method runs, preparing datasets");
prepareDBUnitOperations();
for (DataSetOperation beforeTestOperation : beforeTestOperations)
{
beforeTestOperation.prepare(this);
}
for (DataSetOperation afterTestOperation : afterTestOperations)
{
afterTestOperation.prepare(this);
}
prepared = true;
}
executeOperations(beforeTestOperations);
}
@AfterMethod
public void cleanDataAfterTest()
{
executeOperations(afterTestOperations);
}
private void executeOperations(List<DataSetOperation> list)
{
log.debug("Executing DataSetOperations: " + list.size());
IDatabaseConnection con = null;
try
{
con = getConnection();
disableReferentialIntegrity(con);
for (DataSetOperation op : list)
{
prepareExecution(con, op);
op.execute(con);
afterExecution(con, op);
}
enableReferentialIntegrity(con);
}
finally
{
if (con != null)
{
try
{
con.close();
}
catch (Exception ex)
{
ex.printStackTrace(System.err);
}
}
}
}
protected static class DataSetOperation
{
private LogProvider log = Logging.getLogProvider(DataSetOperation.class);
String dataSetLocation;
ReplacementDataSet dataSet;
DatabaseOperation operation;
protected DataSetOperation()
{
// Support subclassing
}
/**
* Defaults to <tt>DatabaseOperation.CLEAN_INSERT</tt>
*
* @param dataSetLocation location of DBUnit dataset
*/
public DataSetOperation(String dataSetLocation)
{
this(dataSetLocation, DatabaseOperation.CLEAN_INSERT);
}
/**
* Defaults to <tt>DatabaseOperation.CLEAN_INSERT</tt>
*
* @param dataSetLocation location of DBUnit dataset
* @param dtdLocation optional (can be null) location of XML file DTD on classpath
*/
public DataSetOperation(String dataSetLocation, String dtdLocation)
{
this(dataSetLocation, dtdLocation, DatabaseOperation.CLEAN_INSERT);
}
/**
* @param dataSetLocation location of DBUnit dataset
* @param operation operation to execute
*/
public DataSetOperation(String dataSetLocation, DatabaseOperation operation)
{
this(dataSetLocation, null, operation);
}
public DataSetOperation(String dataSetLocation, String dtdLocation, DatabaseOperation operation)
{
if (dataSetLocation == null)
{
this.operation = operation;
return;
}
// Load the base dataset file
InputStream input = Thread.currentThread().getContextClassLoader().getResourceAsStream(dataSetLocation);
try
{
InputStream dtdInput = null;
if (dtdLocation != null)
{
dtdInput = Thread.currentThread().getContextClassLoader().getResourceAsStream(dtdLocation);
}
if (dtdInput == null)
{
this.dataSet = new ReplacementDataSet(new FlatXmlDataSet(input));
}
else
{
this.dataSet = new ReplacementDataSet(new FlatXmlDataSet(input, dtdInput));
}
}
catch (Exception ex)
{
throw new RuntimeException(ex);
}
this.operation = operation;
this.dataSetLocation = dataSetLocation;
}
public IDataSet getDataSet()
{
return dataSet;
}
public DatabaseOperation getOperation()
{
return operation;
}
public void prepare(DBUnitSeamTest test)
{
if (dataSet == null) return;
log.debug("Preparing DataSetOperation replacement values");
if (test.isReplaceNull())
{
log.debug("Replacing [NULL] placeholder with real null value");
dataSet.addReplacementObject("[NULL]", null);
}
if (test.getBinaryDir() != null)
{
log.debug("Replacing [BINARY_DIR] placeholder with path: " + test.getBinaryDirFullpath().toString());
dataSet.addReplacementSubstring("[BINARY_DIR]", test.getBinaryDirFullpath().toString());
}
}
public void execute(IDatabaseConnection connection)
{
if (dataSet == null || operation == null) return;
try
{
log.debug("Executing: " + this);
this.operation.execute(connection, dataSet);
}
catch (Exception ex)
{
throw new RuntimeException(ex);
}
}
@Override
public String toString()
{
return getClass().getName() + " with dataset location: " + dataSetLocation;
}
}
// Subclasses can/have to override the following methods
/**
* Override this method if you want to provide your own DBUnit <tt>IDatabaseConnection</tt> instance.
* <p/>
* If you do not override this, default behavior is to use the * configured datasource name and
* to obtain a connection with a JNDI lookup.
*
* @return a DBUnit database connection (wrapped)
*/
protected IDatabaseConnection getConnection()
{
try
{
if (getDatasourceJndiName() == null)
{
throw new RuntimeException("Please set datasourceJndiName TestNG property");
}
DataSource datasource = ((DataSource) getInitialContext().lookup(getDatasourceJndiName()));
// Get a JDBC connection from JNDI datasource
Connection con = datasource.getConnection();
IDatabaseConnection dbUnitCon = new DatabaseConnection(con);
editConfig(dbUnitCon.getConfig());
return dbUnitCon;
}
catch (Exception ex)
{
throw new RuntimeException(ex);
}
}
/**
* Execute whatever statement is necessary to either defer or disable foreign
* key constraint checking on the given database connection, which is used by
* DBUnit to import datasets.
*
* @param con A DBUnit connection wrapper, which is used afterwards for dataset operations
*/
protected void disableReferentialIntegrity(IDatabaseConnection con)
{
try
{
if (database.equals(HSQL))
{
con.getConnection().prepareStatement("set referential_integrity FALSE").execute(); // HSQL DB
}
else if (database.equals(MYSQL))
{
con.getConnection().prepareStatement("set foreign_key_checks=0").execute(); // MySQL > 4.1.1
}
}
catch (Exception ex)
{
throw new RuntimeException(ex);
}
}
/**
* Execute whatever statement is necessary to enable integrity constraint checks after
* dataset operations.
*
* @param con A DBUnit connection wrapper, before it is used by the application again
*/
protected void enableReferentialIntegrity(IDatabaseConnection con)
{
try
{
if (database.equals(HSQL))
{
con.getConnection().prepareStatement("set referential_integrity TRUE").execute(); // HSQL DB
}
else if (database.equals(MYSQL))
{
con.getConnection().prepareStatement("set foreign_key_checks=1").execute(); // MySQL > 4.1.1
}
}
catch (Exception ex)
{
throw new RuntimeException(ex);
}
}
/**
* Override this method if you require DBUnit configuration features or additional properties.
* <p/>
* Called after a connection has been obtaind and before the connection is used. Can be a
* NOOP method if no additional settings are necessary for your DBUnit/DBMS setup.
*
* @param config A DBUnit <tt>DatabaseConfig</tt> object for setting properties and features
*/
protected void editConfig(DatabaseConfig config)
{
if (database.equals(HSQL))
{
// DBUnit/HSQL bugfix
// http://www.carbonfive.com/community/archives/2005/07/dbunit_hsql_and.html
config.setProperty(DatabaseConfig.PROPERTY_DATATYPE_FACTORY, new DefaultDataTypeFactory()
{
@Override
public DataType createDataType(int sqlType, String sqlTypeName)
throws DataTypeException
{
if (sqlType == Types.BOOLEAN)
{
return DataType.BOOLEAN;
}
return super.createDataType(sqlType, sqlTypeName);
}
});
}
}
/**
* Callback for each operation before DBUnit executes the operation, useful if extra preparation of
* data/tables is necessary, e.g. additional SQL commands on a per-operation (per table?) granularity
* on the given database connection.
*
* @param con A DBUnit connection wrapper
* @param operation The operation to be executed, call <tt>getDataSet()</tt> to access the data.
*/
protected void prepareExecution(IDatabaseConnection con, DataSetOperation operation)
{
}
/**
* Callback for each operation, useful if extra preparation of data/tables is necessary.
*
* @param con A DBUnit connection wrapper
* @param operation The operation that was executed, call <tt>getDataSet()</tt> to access the data.
*/
protected void afterExecution(IDatabaseConnection con, DataSetOperation operation)
{
}
/**
* Resolves the binary dir location with the help of the classloader, we need the
* absolute full path of that directory.
*
* @return URL full absolute path of the binary directory
*/
protected URL getBinaryDirFullpath()
{
if (getBinaryDir() == null)
{
throw new RuntimeException("Please set binaryDir TestNG property to location of binary test files");
}
return getResourceURL(getBinaryDir());
}
protected URL getResourceURL(String resource)
{
URL url = Thread.currentThread().getContextClassLoader().getResource(resource);
if (url == null)
{
throw new RuntimeException("Could not find resource with classloader: " + resource);
}
return url;
}
/**
* Load a file and return it as a <tt>byte[]</tt>. Useful for comparison operations in an actual
* unit test, e.g. to compare an imported database record against a known file state.
*
* @param filename the path of the file on the classpath, relative to configured <tt>binaryDir</tt> base path
* @return the file content as bytes
* @throws Exception when the file could not be found or read
*/
protected byte[] getBinaryFile(String filename) throws Exception
{
if (getBinaryDir() == null)
{
throw new RuntimeException("Please set binaryDir TestNG property to location of binary test files");
}
File file = new File(getResourceURL(getBinaryDir() + "/" + filename).toURI());
InputStream is = new FileInputStream(file);
// Get the size of the file
long length = file.length();
if (length > Integer.MAX_VALUE)
{
// File is too large
}
// Create the byte array to hold the data
byte[] bytes = new byte[(int) length];
// Read in the bytes
int offset = 0;
int numRead;
while (offset < bytes.length
&& (numRead = is.read(bytes, offset, bytes.length - offset)) >= 0)
{
offset += numRead;
}
// Ensure all the bytes have been read in
if (offset < bytes.length)
{
throw new IOException("Could not completely read file " + file.getName());
}
// Close the input stream and return bytes
is.close();
return bytes;
}
/**
* Implement this in a subclass.
* <p/>
* Use it to stack DBUnit <tt>DataSetOperation</tt>'s with
* the <tt>beforeTestOperations</tt> and <tt>afterTestOperations</tt> lists.
*/
protected abstract void prepareDBUnitOperations();
}