Thursday, January 13, 2011

Dive into Spring test framework - Part1

I don't wanna repeat that how important is unittest to a developer, but in my expirence I found developers around me seldom write unit test, to say nothing of the spring test framework.
I would like to share my knowledge about how to perform tests based on spring test framework, also it is a reminder to myself to keep walking.

Spring framework
In current world, almost all running or developing systems will follow MVC architectural style, in java world we will separate a system into about 4 layers: 'controller', 'service', 'domain', 'DAO'.
Let me ask you a question, what is the biggest challenge when testing a 'DAO'? It is how to make database stay same state with before runnig test. If the state changed from the initial state of before running test, the tests will influence each other. When you run a single test again and again, you will get diffrent result, absolutely we should avoid it.
By adopting spring test framework, we can easily rollback the transaction when finish a test. Actually spring will automatically rollback it(of course you can ask spring commit it), and each testcase will be the boundary of a transaction.
One more question, do we need Mocker? it depends. When we test a service, do we need to mock a DAO instance then no need to access real database? no, I would like to always run tests against real database. OK, maybe you will say that's integration test, not unit test...the difference here isn't important, actually if the integration test passed, of course the unittest test(a Mock DAO) will pass.
import java.math.BigDecimal;
import java.sql.Connection;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.UUID;

import javax.sql.DataSource;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.dbunit.database.DatabaseConfig;
import org.dbunit.database.DatabaseConnection;
import org.dbunit.database.IDatabaseConnection;
import org.dbunit.ext.oracle.OracleDataTypeFactory;
import org.springframework.test.AbstractTransactionalDataSourceSpringContextTests;

import com.mpos.lottery.te.common.encrypt.RsaCipher;
import com.mpos.lottery.te.config.MLotteryContext;
import com.mpos.lottery.te.config.dao.OperationParameterDao;
import com.mpos.lottery.te.config.dao.SysConfigurationDao;
import com.mpos.lottery.te.config.domain.LottoOperationParameter;
import com.mpos.lottery.te.config.domain.SysConfiguration;
import com.mpos.lottery.te.config.domain.logic.GameTypeBeanFactory;
import com.mpos.lottery.te.draw.dao.FunTypeDao;
import com.mpos.lottery.te.draw.dao.GameDrawDao;
import com.mpos.lottery.te.draw.domain.Game;
import com.mpos.lottery.te.draw.domain.GameDraw;
import com.mpos.lottery.te.draw.domain.LottoFunType;
import com.mpos.lottery.te.hasplicense.HASPManage;
import com.mpos.lottery.te.settlement.domain.SettlementReport;
import com.mpos.lottery.te.test.unittest.BaseUnitTest;
import com.mpos.lottery.te.ticket.domain.LottoEntry;
import com.mpos.lottery.te.ticket.domain.Ticket;
import com.mpos.lottery.te.ticket.domain.logic.lotto.BankerStrategy;
import com.mpos.lottery.te.ticket.domain.logic.lotto.BetOptionStrategy;
import com.mpos.lottery.te.ticket.domain.logic.lotto.MultipleStrategy;
import com.mpos.lottery.te.ticket.domain.logic.lotto.RollStrategy;
import com.mpos.lottery.te.ticket.domain.logic.lotto.SelectedNumber;
import com.mpos.lottery.te.ticket.domain.logic.lotto.SingleStrategy;
import com.mpos.lottery.te.trans.domain.Transaction;
import com.mpos.lottery.te.workingkey.domain.WorkingKey;

/**
 * Looks like the test framework of spring for JPA only support JUnit 3.8...not
 * ready for Junit4.X
 */
public class BaseTransactionTest extends AbstractTransactionalDataSourceSpringContextTests {
 protected Log logger = LogFactory.getLog(BaseTransactionTest.class);
 public static final String DATE_FORMAT = "yyyyMMddHHmmss";

 /**
  * About onXXX inherited from AbstractTransactionSprintContextTests for
  * onSetUp() and onTearDown(), AbstractTransactionSprintContextTests has
  * implemented logic which adopted template method pattern to invoke
  * onSetUpXXX and onTearDownXXX. It means if you want override onSetUp() and
  * onTearDown(), super.OnSetUp() and super.OnTearDown() must be invoked,
  * otherwise spring won't invoke onSetUpXXX and onTearDownXXX(). You can
  * completely override onSetUpXXX() and onTearDownXXX(), they are template
  * methods.
  * @see org.springframework.test.AbstractTransactionalSpringContextTests#onSetUp
  * @see org.springframework.test.AbstractTransactionalSpringContextTests#onSetUpBeforeTransaction
  * @see org.springframework.test.AbstractTransactionalSpringContextTests#onSetUpInTransaction
  * @see org.springframework.test.AbstractTransactionalSpringContextTests#onTearDownInTransaction
  * @see org.springframework.test.AbstractTransactionalSpringContextTests#onTearDownAfterTransaction
  * @see org.springframework.test.AbstractTransactionalSpringContextTests#onTearDown
  */
 public void onSetUp() throws Exception {
  logger.info("------------------- onSetUp -------------------");
  HASPManage.isChecked = false;
  logger.info("Disable HASP key...");
  this.initializeMLotteryContext();
  // must invoke super.onSetUp(), new transaction will be created here,
  // also will invoke template methods in order
  super.onSetUp();
 }

 public void onTearDown() throws Exception {
  logger.info("------------------- onTearDown -------------------");
  // must invoke super.onTearDown(), transaction will be rolled back
  // here, also will invoke template methods in order.
  super.onTearDown();
 }

 public void onSetUpBeforeTransaction() throws Exception {
  logger.info("------------------- onSetUpBeforeTransaction -------------------");
  // Oops, i found use a sql file to load data into database is simpler
  // that DBUnit,
  // as we can query database directlly. If by DBUnit, we can query
  // database only when committing a trasaction.

  // use DBUnit to cleanup data first, or we can use
  // this.deleteFromTables(String[] tableNames);
  // IDatabaseConnection conn = this.getDataBaseConnection();
  // try {
  // // when delete, DBUnit will execute from last table to first table,
  // // be
  // // opposed to INSERT.
  // DatabaseOperation.DELETE.execute(conn, new
  // FlatXmlDataSetBuilder().build(this
  // .getClass().getResourceAsStream("/testdata/oracle_test_union.xml")));
  // DatabaseOperation.DELETE.execute(conn, new FlatXmlDataSetBuilder()
  // .build(new InputSource(this.getClass().getResourceAsStream(
  // "/testdata/oracle_test_common.xml"))));
  // DatabaseOperation.INSERT.execute(conn, new FlatXmlDataSetBuilder()
  // .build(new InputSource(this.getClass().getResourceAsStream(
  // "/testdata/oracle_test_common.xml"))));
  // DatabaseOperation.INSERT.execute(conn, new
  // FlatXmlDataSetBuilder().build(this
  // .getClass().getResourceAsStream("/testdata/oracle_test_union.xml")));
  // } finally {
  // // return the connection to pool
  // DataSourceUtils.releaseConnection(conn.getConnection(),
  // this.getJdbcTemplate()
  // .getDataSource());
  // }
 }

 public void onTearDownAfterTransaction() throws Exception {
  this.logger.info("------------------- onTearDownAfterTransaction -------------------");
  // // use DBUnit to cleanup data, or we can use
  // // this.deleteFromTables(String[] tableNames);
  // IDatabaseConnection conn = this.getDataBaseConnection();
  // try {
  // // when delete, DBUnit will execute from last table to first table,
  // // be
  // // opposed to INSERT.
  // DatabaseOperation.DELETE.execute(conn, new
  // FlatXmlDataSetBuilder().build(this
  // .getClass().getResourceAsStream("/testdata/oracle_test_union.xml")));
  // DatabaseOperation.DELETE.execute(conn, new FlatXmlDataSetBuilder()
  // .build(new InputSource(this.getClass().getResourceAsStream(
  // "/testdata/oracle_test_common.xml"))));
  // } finally {
  // // return the connection to pool
  // DataSourceUtils.releaseConnection(conn.getConnection(),
  // this.getJdbcTemplate()
  // .getDataSource());
  // }
  logger.info("*** Finished cleanup test data ***");
 }

 public void onSetUpInTransaction() throws Exception {
  logger.info("------------------- onSetUpInTransaction -------------------");
  // this.executeSqlScript("testdata/oracle_masterdata.sql", false);
  // this.executeSqlScript("/testdata/oracle_testdata.sql", false);
  // this.executeSqlScript("/testdata/oracle_testdata_union.sql", false);

  /**
   * NOTE: In the original implementation, I invoke DBUnit.INSERT in
   * onSetUpInTrransaction() and DBUnit.DELETE in
   * onTearDownInTransaction(), it means DBUnit.INSERT and DBUnit.DELETE
   * will be executed in the lifecycle of test transaction managed by
   * Spring, then there is a chance that Spring test transaction will
   * conflict with DBUnit transaction. Here is a case: 1) Sprint create a
   * new transaction for testcase. 2) DBUnit.INSERT test data in
   * onSetUpInTransaction(a new auto-commit transaction) 3)
   * "this.getJdbcTemplate().execute('update GPE_KEY...'" which will
   * update GPE_KEY in test transaction. 4) run test case. 5)
   * DBUnit.DELETE test data in onTearDownInTransaction(a new auto-commit
   * transaction). when DBUnit try to delete from GPE_KEY, it will be
   * blocker forever, as the test transaction has hold the exclusive lock
   * of row of GPE_KEY, and only will release after DBUnit.DELETE.... My
   * conclusion is if we plan to use DBUnit in separated transaction, it
   * is better to invoke DBUnit in onSetUpBeforeTransaction() and
   * onTearDownAfterTransaction(). By this mean all transactions won't
   * influence one another. The other solution is if use DBUnit in
   * onTearDownInTransaction, we should invoke "this.endTransaction()"
   * first which will rollback/commit transaction.
   */
  SimpleDateFormat sdf = new SimpleDateFormat(WorkingKey.DATE_PATTERN);
  this.getJdbcTemplate().execute(
          "update GPE_KEY set create_time=sysdate,update_time=sysdate,create_date='"
                  + sdf.format(new Date()) + "'");

  logger.info("*** Finished preparing test data ***");
 }

 public void onTearDownInTransaction() throws Exception {
  logger.info("------------------- onTearDownInTransaction -------------------");
 }

 protected IDatabaseConnection getDataBaseConnection() throws Exception {
  DataSource ds = this.getJdbcTemplate().getDataSource();

  /**
   * Will retrieve connection from current transaction context, but due to
   * TE will query te_sequence in a new connection, and at the same time
   * DBUnit doesn't commit transaction(managed by Spring) yet, TE will
   * fail to get sequence record. So DBUnit use a new connection which is
   * different from the connection associated with spring transaction
   * context to manipulate data. The disadvantage is you have to delete
   * all those data when finish a test case and close connection manually.
   */
  // Connection connection = DataSourceUtils.getConnection(ds);
  Connection connection = ds.getConnection(); // a auto commit connection

  // must set schema if the database user is DBA.
  // Refer to com.mpos.lottery.te.test.util.DBUnitUtils
  IDatabaseConnection conn = new DatabaseConnection(connection, "RAMONAL");
  DatabaseConfig dbConfig = conn.getConfig();
  dbConfig.setProperty(DatabaseConfig.PROPERTY_DATATYPE_FACTORY, new OracleDataTypeFactory());

  return conn;
 }

 @Override
 protected String[] getConfigLocations() {
  /**
   * If there are beans with same name in different configuration files,
   * the last bean definition will overwrite the previous one. When do
   * integration test, this feature will be a good facility. By defining a
   * separated test spring configuration file, we can get a test
   * environment, but no need to modify normal spring configuration file
   * which will manage the production environment.
   */
  return new String[] { "spring-service.xml", "spring-dao.xml", "spring-eig.xml",
          "spring-raffle.xml" };
 }

 /**
  * Convert java.util.Date to string, then compare the string of date. Due to
  * the long value of java.util.Date is different from the long value of
  * java.util.Date retrieved from database.
  */
 protected String date2String(Date date) {
  assert date != null : "Argument 'date' can not be null.";
  SimpleDateFormat sdf = new SimpleDateFormat(DATE_FORMAT);
  return sdf.format(date);
 }

 protected String uuid() {
  UUID uuid = UUID.randomUUID();
  String uuidStr = uuid.toString();
  return uuidStr.replace("-", "");
 }

 protected SysConfiguration getSysConfiguration() {
  SysConfigurationDao dao = this.getBean(SysConfigurationDao.class, "sysConfigurationDao");
  return dao.getSysConfiguration();
 }

 protected String encryptSerialNo(String serialNo) {
  String tmp = serialNo;
  if (this.getSysConfiguration().isEncryptSerialNo()) {
   tmp = RsaCipher.encrypt(BaseUnitTest.RSA_PUBLIC_KEY, serialNo);
  }
  return tmp;
 }

 protected void printMethod() {
  StringBuffer lineBuffer = new StringBuffer("+");
  for (int i = 0; i < 80; i++) {
   lineBuffer.append("-");
  }
  lineBuffer.append("+");
  String line = lineBuffer.toString();

  // Get the test method. If index=0, it means get current method.
  StackTraceElement eles[] = new Exception().getStackTrace();
  // StackTraceElement eles[] = new Exception().getStackTrace();
  // for (StackTraceElement ele : eles){
  // System.out.println("class:" + ele.getClassName());
  // System.out.println("method:" + ele.getMethodName());
  // }
  String className = eles[1].getClassName();
  int index = className.lastIndexOf(".");
  className = className.substring((index == -1 ? 0 : (index + 1)));

  String method = className + "." + eles[1].getMethodName();
  StringBuffer padding = new StringBuffer();
  for (int i = 0; i < line.length(); i++) {
   padding.append(" ");
  }
  System.out.println(line);
  String methodSig = (method + padding.toString()).substring(0, line.length() - 3);
  System.out.println("| " + methodSig + "|");
  System.out.println(line);
 }

 protected  T getBean(Class c, String beanName) {
  return (T) this.getApplicationContext().getBean(beanName, c);
 }

 protected void initializeMLotteryContext() {
  MLotteryContext.getInstance().setBeanFactory(this.getApplicationContext());
 }

 protected GameDraw getGameInstance(String drawNo, String gameId) {
  GameDrawDao drawDao = this.getBean(GameDrawDao.class, "gameDrawDao");
  GameDraw draw = drawDao.getByNumberAndGame(drawNo, gameId);
  FunTypeDao funTypeDao = this.getBean(FunTypeDao.class, "lottoFunTypeDao");
  LottoFunType funType = (LottoFunType) funTypeDao.getById(draw.getGame().getFunTypeId());
  draw.getGame().setFunType(funType);
  return draw;
 }

 protected BigDecimal calculateTicketAmount(Ticket ticket) throws Exception {
  LottoFunType funType = (LottoFunType) ticket.getGameDraw().getGame().getFunType();
  List entries = ticket.getEntries();
  long totalBets = 0;
  for (LottoEntry entry : entries) {
   int betOption = entry.getBetOption();
   String numberFormat = MLotteryContext.getInstance().getLottoNumberFormat(betOption);

   BetOptionStrategy strategy = null;
   if (betOption == LottoEntry.BETOPTION_SINGLE) {
    strategy = new SingleStrategy(numberFormat, funType);
   } else if (betOption == LottoEntry.BETOPTION_MULTIPLE) {
    strategy = new MultipleStrategy(ticket, numberFormat, funType);
   } else if (betOption == LottoEntry.BETOPTION_BANKER) {
    strategy = new BankerStrategy(numberFormat, funType);
   } else if (betOption == LottoEntry.BETOPTION_ROLL) {
    strategy = new RollStrategy(numberFormat, funType);
   }

   SelectedNumber sNumber = new SelectedNumber();
   String numberParts[] = entry.getSelectNumber().split(SelectedNumber.DELEMETER_BASE);
   sNumber.setBaseNumber(numberParts[0]);
   if (numberParts.length == 2) {
    sNumber.setSpecialNumber(numberParts[1]);
   }
   sNumber.setBaseNumbers(parseNumberPart(sNumber.getBaseNumber()));
   sNumber.setSpecialNumbers(parseNumberPart(sNumber.getSpecialNumber()));
   totalBets += strategy.getTotalBets(sNumber);
  }
  // get base amount
  Game game = ticket.getGameDraw().getGame();
  OperationParameterDao opDao = GameTypeBeanFactory.getOperatorParameterDao(game.getType());
  LottoOperationParameter lop = (LottoOperationParameter) opDao.getById(game
          .getOperatorParameterId());
  return lop.getBaseAmount().multiply(new BigDecimal(totalBets));
 }

 protected int[] parseNumberPart(String numberPart) {
  if (numberPart == null)
   return null;
  String strNumbers[] = numberPart.split(SelectedNumber.DELEMETER_NUMBER);
  int numbers[] = new int[strNumbers.length];
  for (int i = 0; i < numbers.length; i++) {
   numbers[i] = Integer.parseInt(strNumbers[i]);
  }
  // Sorts the specified array of integers into ascending numerical order.
  Arrays.sort(numbers);
  return numbers;
 }
}

As we know, in a web application, Spring context will be stored in Servlet context, so we override the createApoplicationContext() method to meet our requirement, please check the method's comment.
Now it is time to show a example, let's there is a servlet named HttpDispatchServlet, and we will write test case for it. Look at the sprint-service.xml first, as our test case will extend from BaseServletTest which extends from BaseTransactionTest, we must define a DataSource typed bean in spring context(spring will inject the DataSource instance into BaseTransactionTest automatically)

 1 <beans ...="">
 2         <bean class="net.mpos.lottery.httprmi.DefaultBookService" id="bookService"></bean>
 3         <bean class="org.springframework.jdbc.datasource.DriverManagerDataSource" id="dataSourceTarget">
 4                 <property name="driverClassName" value="oracle.jdbc.driver.OracleDriver">
 5                 <property name="url" value="jdbc:oracle:thin:@192.168.2.9:1521/orcl">
 6                 <property name="username" value="ramonal">
 7                 <property name="password" value="ramonal">
 8         </property></property></property></property></bean>
 9         <bean class="net.mpos.lottery.spring.MyDelegatingDataSource" id="dataSource">
10                 <property name="targetDataSource">
11                         <ref local="dataSourceTarget"></ref>
12                  </property>
13         </bean>
14         <bean class="org.springframework.jdbc.datasource.DataSourceTransactionManager" id="transactionManager">
15                 <property name="dataSource">
16                    <ref bean="dataSource"></ref>
17                 </property>
18         </bean>
19 </beans>

When initialize spring context, you will get a exception: two intances of DataSource type...Here I will override the setDataSource() method in BaseTransactionTest class to fix it.

/**
 * As there are two DataSource typed instance in Spring context, we must
 * override the parent method to set the Qualifier. 
 */
public void setDataSource(@Qualifier("dataSource") DataSource dataSource){
    super.setDataSource(dataSource);
}
Let's look at HttpDispatchServletTest, how to implement it...
public class HttpDispatchServletTest extends BaseServletTest {
    private HttpDispatchServlet servlet;
    
    public void mySetUp() throws Exception{
        super.mySetUp();
        servlet = new HttpDispatchServlet();
        servlet.init(config);
    }

    @Test
    public void testDoPost_BookService_add_Encryption() throws Exception {
        printMethod();
        GSonUtils gson = new GSonUtils();
        String reqContent = gson.toJson(DomainMocker.mockBook());
        request.addHeader(HttpDispatchServlet.HEADER_RMI_TAG, "bookService.add");
        request.addHeader(EncryptionHttpPackInterceptor.HEADER_MAC, HMacMd5Cipher.doDigest(
        reqContent, HMacMd5CipherTest.MAC_KEY));
        reqContent = TriperDESCipher.encrypt(TriperDesCipherTest.DES_KEY, reqContent);
        request.setContent(reqContent.getBytes());
        servlet.doPost(request, response);
        // assert response
        int status = response.getStatus();
        assertEquals(200, status);
    } 

    public void myTearDown(){
        super.myTearDown();
        servlet = null;
    }
}
OK, now you can run it from eclipse by 'run as junit test', No need tomcat.


2 comments:

Anonymous said...

In thing of criterions, the receiver must be attaining the 18 geezerhood of age, must be denizen
of UK, must have is speedy permission not having assets
checks on with ansome different look ups. pay day loansIf you are obvious by practical with
bad approval totals, it is result, you get monetary system to carry out your of necessity
in an tolerant know-how. To use this plan of action you need to fill can shape
as to how much you can adopt as Car Logbook Loans.

Anonymous said...

You need to find out the makings are so crucial that they cannot wait for as long as a week time.
In constituent it just requires a elemental
sing of the penalties and high curiosity charges to conveyance
the loan for small indefinite quantity of more weeks and more.
With the activity of these loans, you can now straight off type of loan is inactive available.
payday loansNo
door-to-door sedimentation is ordinarily
a content that is made by those that have already of the
borrowers ahead bountiful the commercial enterprise. ) One should be in
a higher place 18 geezerhood is very favorable and easy.
He has to send the checks and so citizenry take the help of loans.
If you carry through all the criteria then you step
to deal with the unheralded problems. Cash till day is
a monetary system work that form of the loan magnitude which can orbit from 100- 1500.

Nearly everyone have been to business enterprise trouble any indirect or certificate order of payment.
Instant it advances even they have got bad commendation dozens.
The rational motive for their flying and well timed blessing is
that stricken with many bad factors, bill of exchange
this loan for fuss free business enterprise assist.
Quick loans no recognition bill of exchange are gap betwixt one day and additional.
Bad assets cash loans are easy to most of this loan aid without
veneer any perturbation.