Saturday, August 18, 2007

Unit Testing สำหรับ DAO

ปกติเวลาเขียน unit testing
ผมมักจะมีปัญหากับ test ในส่วนของ dao, service layer อยู่มาก
เนื่องจากมันต้องยุ่งเกี่ยวกับ Database
ซึ่งหมายถึงต้องมี Test Data เตรียมไว้ให้เรียบร้อย
ที่แย่ก็คือ พอ testing ที่ไปยุ่งกับ update, delete
มันก็จะทำให้ state ของ data ใน database เราเปลี่ยนไป

ดังนี้ปกติที่ผมทำ ก็คือ
เวลา test ใน service layer ก็ใช้พวก mock กับ stub มาช่วย
จะได้ไม่ต้องยุ่งกับ database

ส่วนใน dao layer ก็เอาพวก DBUnit มาใช้
ซึ่งก็ช่วยได้พอสมควร
แต่ก็รู้สึกว่าการใช้ DBUnit ก็ดูเป็นการทรมาน database ไปหน่อย
(delete, insert ทั้งหมด ทุกๆ test method)
หลังๆ เวลา test ก็เปลี่ยนมาใช้พวก in memory database แทน
ซึ่งผลพลอยได้ ก็คือประหยัดเวลา test ลงไปด้วย

แต่พอเป็นโปรเจคใหญ่ๆที่มี table สัก 200 table
มี data test เยอะพอสมควร
การใช้ DBUnit ก็ดูจะไม่ไหว

สุดท้าย ก็ไปลงเอยที่ AbstractTransactionalDataSourceSpringContextTests
ที่ spring เตรียมไว้ให้
โดยหลักการ ก็คือ spring มันจะเตรียม transaction ไว้ให้เราก่อนเรียก method test
และพอจบ method, spring ก็จะสั่ง rollback ให้
ซึ่งเป็นแนวคิดที่เรียบง่าย แต่ได้ผลดีมาก

แต่ก็มีปัญหาอยู่บ้าง ในกรณีที่เราใช้ Hibernate
hibernate มันจะ cache sql ไว้ (รอให้จบ transaction ก่อนจึงจะทำงาน)
ทำให้เกิดปัญหาว่า assert statement ของเรา
ทำงานไม่ถูกต้อง
ทางแก้ก็คือ เราต้องสั่งให้ hibernate flush sql statement
ก่อนที่จะทำการ assert

ปัญหาอีกอย่างที่เจอ ก็คือ ผมใช้ TestNG อยู่
แต่เจ้าตัว AbstractTransactionalDataSourceSpringContextTests
มัน base อยู่บน JUnit
ก็เลยต้องมีการดัดแปลงนิดๆหน่อยๆ
แต่ผลที่ได้ก็เจ๋งไปอีกแบบคือเลือก run ได้ทั้ง Junit และ TestNG
(พึ่งรู้ว่า เราใช้คำสั่ง assert* ของ Junit มา run ใน TestNG ได้ด้วย)

หน้าตาของ base class ของ testcase ผมก็จะเป็นแบบนี้
public class AbstractDaoTest extends
AbstractTransactionalDataSourceSpringContextTests {

protected SessionFactory sessionFactory;
protected HibernateTemplate hibernateTemplate;

public void setSessionFactory(SessionFactory sessionFactory) {
this.sessionFactory = sessionFactory;
hibernateTemplate = new HibernateTemplate(this.sessionFactory);
}

@Override
protected String[] getConfigLocations() {
return new String[] { "classpath:test-dao.xml" };
}

@Configuration(beforeTestMethod = true, alwaysRun = true)
public void tNGsetUp() throws Exception {
super.setUp();
}

@Configuration(afterTestMethod = true, alwaysRun = true)
public void tNGtearDown() throws Exception {
super.tearDown();
}

protected void flush() {
hibernateTemplate.flush();
}
}


ตัวอย่าง test case
public class TestUserDao extends AbstractDaoTest {

UserDao dao;

@Test
public void testCreateRole() {
assertFalse(dao.getFetchRoles("u190240").isUserInRole("testing"));
dao.addRole("u190240", "testing");

flush();

assertTrue(dao.getFetchRoles("u190240").isUserInRole("testing"));
}

@Test
public void testFindAll() {
List<User> list = dao.findAll();
int cnt = jdbcTemplate.queryForInt("select count(*) from b_user_name");
assertEquals(cnt, list.size());
}

public void setDao(UserDao dao) {
this.dao = dao;
}

}

ข้อดีอีกอย่างของ AbstractTransactionalDataSourceSpringContextTests
ก็คือมัน inject dao เข้ามาให้เราโดยอัตโนมัติ
ไม่ต้องมา getBean เอง

Related link from Roti

Friday, August 17, 2007

bug bug bug

หลังจากลูกชายคนที่สองครบ 3 เดือน
แม่เด็กมีแรงดูลูกทีเดียว 2 คนแล้ว
ก็เลยได้ฤกษ์กลับไปทำงาน

วันแรกที่ทำ ก็เจอ bug ไปหลายตัวทีเดียว

เริ่มที่ตัวนี้ก่อน
เกิดจากการ update Hibernate-Annotation ไปเป็น version 3.3.0.ga
ปรากฎว่าพอสร้าง SessionFactory ก็จะเกิด exception นี้ขึ้น
Caused by: java.lang.NullPointerException
at org.hibernate.cfg.annotations.CollectionBinder.buildOrderByClauseFromHql(CollectionBinder.java:851)

ค้นดูแล้ว เป็น bug ANN-617
มีสาเหตุจากพวก @OrderBy annotation ในพวก one-to-many
เจอแล้ว ก็ถอย version กลับไปที่ 3.2.1.ga

ตัวที่สองเป็น bug ของ maven cobertura plugin
เป็นกับพวกที่ใช้ windows platform
โดยตอนที่มัน instrument class
file cobertura.ser มันสร้างไว้ผิดที่
ทำให้ตอน testing มันได้ผลลัพท์เป็น 100% coverage ตลอด
แก้โดยการกำหนด version เป็น 2.0 ลงใน pom.xml
        <plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>cobertura-maven-plugin</artifactId>
<!-- เพิ่มตรงนี้เข้าไป -->
<version>2.0</version>
...


ตัวที่สามเป็น bug ใน maven mojo plugin บน Linux
มันจะฟ้องว่า
java.io.IOException: java: not found 

เกิดจาก execute file "java" ไม่ได้อยู่ใน system path (พวก /usr/bin, /usr/local/bin)
ทำ link ให้แล้วก็หาย

นอกเรื่อง:
กลับมาถึงบ้าน ลูกชายวิ่งเข้ามากระโดดเข้ากอด
ชื่นใจจริงๆ

Related link from Roti

Wednesday, August 15, 2007

Seed Data

ผมชอบวิธี load data ใน Ofbiz ที่ใช้วิธีประกาศ
<entity-resource type="data" reader-name="seed" loader="main" location="data/AccountingTypeData.xml"/>

ส่วน file ที่เก็บ data จะมีหน้าตาแบบนี้

<GlAccount parentGlAccountId="" glAccountId="100000" accountCode="100000"
glAccountClassId="ASSET" glAccountTypeId="" glResourceTypeId="MONEY"
accountName="ASSETS" description="" postedBalance="0.0"/>

<GlAccount parentGlAccountId="100000" glAccountId="110000" accountCode="110000"
glAccountClassId="CASH_EQUIVALENT" glAccountTypeId="CURRENT_ASSET" glResourceTypeId="MONEY"
accountName="CASH" description="" postedBalance="0.0"/>

<GlAccount parentGlAccountId="110000" glAccountId="111000" accountCode="111000"
glAccountClassId="CASH_EQUIVALENT" glAccountTypeId="CURRENT_ASSET" glResourceTypeId="MONEY"
accountName="CASH IN BANK AND ON HAND" description="" postedBalance="0.0"/>


ตอนนี้กำลังเขียนโปรแกรมบัญชีด้วย Rails อยู่เหมือนกัน
ก็เลยอยากลอกวิธี setup seed data มาไว้ใช้บ้าง

เดิิมใน rails เราสามารถ load data ที่อยู่ในรูป format YML ได้เหมือนกัน
โดย rails เรียกว่า Fixtures
แต่ข้อเสียของ Fixtures ก็คือ
มันแยกเก็บเป็น 1 file ต่อ 1 table
ซึ่งสำหรับผม ในการ config ที่ cross ไป cross มา
มันมองภาพรวมยากไปนิด

กลับมาเข้าเรื่องต่อ ถ้าเราลอก feature seed data มาทำด้วย Rails
ก็จะมี constraint ในการ design อยู่ 2 เรื่องคือ
1. เราคงไม่ใช้ xml
2. ใน ofbiz, primary key มันเป็น String, ทำให้ง่ายต่อการ reference กันระหว่าง entity
แต่ใน ruby, primary key มันเป็น integer ดังนั้นต้องมี concept ที่ใช้ตั้งชื่อ record
เพื่อให้ง่ายต่อการอ้างใช้

ตัว syntax เมื่อไม่ใช้ xml ก็ลองเอา block มาใช้ดู
หน้าตาแบบนี้
setup do |s|

s.model :AcctChart do |m|
m.data :all, :code => "all", :name => "Mx Group"
end

s.model :AcctGroup do |m|
m.data :cash, :description => "Cash"
m.data :liability, :description => "Liability"
m.data :equity, :description => "Equity"
m.data :revenue, :description => "Revenue"
m.data :expense, :description => "Expense"
end

s.model :AcctEntity do |m|
m.data :mx, :code => "mx",
:party_id => s.Organization[:mx],
:acct_chart_id => s.AcctChart[:all]
end

s.model :AcctChartItem do |m|
m.data 1000,
:code => "1000",
:long_name => "cash"
m.data 1001,
:code => "1010",
:short_name => "cash on hand",
:acct_chart_id => s.AcctChart[:all],
:acct_group_id => s.AcctGroup[:cash],
:parent_id => m[1000]
m.data 1002,
:code => '1020',
:short_name => "cash in bank",
:acct_chart_id => s.AcctChart[:all],
:acct_group_id => s.AcctGroup[:cash],
:parent_id => m[1000]
end

end

จะเห็นว่า เราใช้วิธีอ้างถึง record ที่เกิดขึ้นแล้ว อยู่ 2 แบบ

:acct_chart_id => s.AcctChart[:all]

:parent_id => m[1000]

ทั้งสองวิธีใช้ hash เหมือนกัน แต่ต่างกันที่ scope ที่ระบุแค่นั้น

สำหรับการ implement, ประเด็นที่สนุกหน่อย ก็คือ
เรารู้ Model Class โดยชื่อของมัน
จากชื่อ เราจะ solve ไปเป็น class Object ได้อย่างไร
อย่างใน java เราก็จะมี Syntax พวกนี้ใช้
Class.forName("x.y.Z").newInstance()


ใน ruby ถ้าเล่นแบบลูกทุ่งสุด ก็ง่ายๆแบบนี้

eval("#{clsName}.new")


ถ้าให้สวยขึ้น ก็ต้องมองที่ concept ว่า Class ใน ruby
มันเก็บไว้เป็น constant ใน Object
ดังนั้นเราสามารถใช้คำสั่งแบบนี้ได้
Objet.const_get(clsName).new


ตัว code เต็มๆ หน้าตาแบบนี้
module DataUtil
module Setup
def setup()
setup = Setup.new()
yield setup
end

class Setup
def initialize()
@cache = {}
end

def model(name)
puts "loading model #{name}"
m = Model.new(name)
@cache[name] = m
yield m
end

def method_missing(sym)
@cache[sym]
end
end

class Model
def initialize(name)
# delete old data
Object.const_get(name).delete_all
@name = name
@cache = {}
end

def data(id, attrs)
m = (Object.const_get @name).new(attrs)
m.save
internal_id = m.id
@cache[id] = internal_id
end

def [](key)
@cache[key]
end
end
end
end


ตัว config ทำให้สวยขึ้นได้กว่านี้
เช่น เปลี่ยนไปอ้างตรงๆเลย แทนที่จะใช้ hash accessor
:acct_chart_id => :all,
:acct_group_id => :cash,

แต่เข้าข่าย 80-20 แล้ว
เมื่อได้ feature เพียงพอต่อการใช้งานแล้ว ก็จงหยุด

Related link from Roti