如何在ReactiveCocoa中寫(xiě)單元測(cè)試

現(xiàn)在很多人在開(kāi)發(fā)iOS時(shí)都使用ReactiveCocoa援所,它是一個(gè)函數(shù)式和響應(yīng)式編程的框架,使用Signal來(lái)代替KVO臂痕、Notification匾南、Delegate和Target-Action等傳遞消息和解決對(duì)象之間狀態(tài)與狀態(tài)的依賴(lài)過(guò)多問(wèn)題。但很多時(shí)候使用它之后馏锡,如何編寫(xiě)單元測(cè)試來(lái)驗(yàn)證程序是否正確呢雷蹂?下面首先了解MVVM架構(gòu),然后通過(guò)一個(gè)例子來(lái)講述我如何在RAC(ReactiveCocoa簡(jiǎn)稱(chēng))中使用Kiwi來(lái)編寫(xiě)單元測(cè)試杯道。

MVVM架構(gòu)

MVVM high level

在MVVM架構(gòu)中匪煌,通常都將view和view controller看做一個(gè)整體。相對(duì)于之前MVC架構(gòu)中view controller執(zhí)行很多在view和model之間數(shù)據(jù)映射和交互的工作,現(xiàn)在將它交給view model去做萎庭。
至于選擇哪種機(jī)制來(lái)更新view model或view是沒(méi)有強(qiáng)制的霜医,但通常我們都選擇ReactiveCocoa。ReactiveCocoa會(huì)監(jiān)聽(tīng)model的改變?nèi)缓髮⑦@些改變映射到view model的屬性中驳规,并且可以執(zhí)行一些業(yè)務(wù)邏輯肴敛。

舉個(gè)例子來(lái)說(shuō),有一個(gè)model包含一個(gè)dateAdded的屬性吗购,我想監(jiān)聽(tīng)它的變化然后更新view model的dateAdded屬性医男。但model的dateAdded屬性的數(shù)據(jù)類(lèi)型是NSDate,而view model的數(shù)據(jù)類(lèi)型是NSString捻勉,所以在view model的init方法中進(jìn)行數(shù)據(jù)綁定镀梭,但需要數(shù)據(jù)類(lèi)型轉(zhuǎn)換。示例代碼如下:

RAC(self,dateAdded) = [RACObserve(self.model,dateAdded) map:^(NSDate*date){ 
    return [[ViewModel dateFormatter] stringFromDate:date];
}];

ViewModel調(diào)用dateFormatter進(jìn)行數(shù)據(jù)轉(zhuǎn)換踱启,且方法dateFormatter可以復(fù)用到其他地方报账。然后view controller監(jiān)聽(tīng)view model的dateAdded屬性且綁定到label的text屬性。

RAC(self.label,text) = RACObserve(self.viewModel,dateAdded);

現(xiàn)在我們抽象出日期轉(zhuǎn)換到字符串的邏輯到view model禽捆,使得代碼可以測(cè)試復(fù)用笙什,并且?guī)蛌iew controller瘦身

登錄情景

登錄情景
登錄情景

如圖所示胚想,這是一個(gè)簡(jiǎn)單的登錄界面:有用戶(hù)名和密碼的兩個(gè)輸入框琐凭,一個(gè)登錄按鈕。用戶(hù)輸入完用戶(hù)名和密碼后浊服,點(diǎn)擊登錄按鈕后统屈,成功登錄。但這里有限制條件:用戶(hù)名必須滿(mǎn)足郵件的格式和密碼長(zhǎng)度必須在6位以上牙躺。當(dāng)同時(shí)滿(mǎn)足這兩個(gè)條件后才能點(diǎn)擊按鈕愁憔,否則按鈕是不可點(diǎn)擊的。大家可以從github中下載實(shí)例代碼孽拷。

首先我們先畫(huà)界面吨掌,我定義一個(gè)LoginView,將畫(huà)登錄界面的責(zé)任都交給它脓恕。然后在LoginViewController中的viewDidLoad方法調(diào)用buildViewHierarchy加載它

#pragma mark - Lifecycle
- (void)viewDidLoad {
    [super viewDidLoad];

    // build view hierarchy
    [self buildViewHierarchy];
    // bind data
    [self bindData];
    // handle events
    [self handleEvents];
}

- (void)buildViewHierarchy
{
    [self.view addSubview:self.rootView];
    [self.rootView mas_makeConstraints:^(MASConstraintMaker *make) {
        make.edges.equalTo(self.view);
    }];
}

接下來(lái)我們要思考UI如何交互和如何設(shè)計(jì)和實(shí)現(xiàn)哪些類(lèi)來(lái)處理膜宋。由于用戶(hù)名和密碼要同時(shí)滿(mǎn)足驗(yàn)證格式時(shí)才能點(diǎn)擊登錄按鈕,所以需要時(shí)刻監(jiān)聽(tīng)usernameTextFieldpasswordTextField的text屬性炼幔,對(duì)于處理UI交互秋茫、數(shù)據(jù)校驗(yàn)以及轉(zhuǎn)換都交給MVVM架構(gòu)中ViewModel來(lái)處理。于是定義一個(gè)LoginViewModel,并繼承RVMViewModel乃秀,這個(gè)RVMViewModel有個(gè)active屬性來(lái)表示viewModel是否處于活躍狀態(tài)肛著,當(dāng)active是YES時(shí)圆兵,更新或顯示UI。當(dāng)active是NO時(shí)枢贿,不更新或隱藏UI殉农。

@interface LoginViewModel : RVMViewModel

#pragma mark - UI state
/*
 @brief 用戶(hù)名
 */
@property (copy, nonatomic) NSString *username;
/*
 @brief 密碼
 */
@property (copy, nonatomic) NSString *password;

#pragma mark - Handle events
/*
 @brief 處理用戶(hù)民和密碼是否有效才能點(diǎn)擊按鈕以及登陸事件
 */
@property (nonatomic, strong) RACCommand *loginCommand;

#pragma mark - Methods
- (RACSignal *)isValidUsernameAndPasswordSignal;

@end

上面還有一個(gè)loginCommand屬性和isValidUsernameAndPasswordSignal方法等下會(huì)詳細(xì)介紹。定義LoginViewModel類(lèi)后局荚,在LoginViewController組合和委托的方式來(lái)使用LoginViewModel并使用Lazy Initialization來(lái)初始化它统抬。

@interface LoginViewController ()

#pragma mark - View model
@property (strong, nonatomic) LoginViewModel *loginViewModel;

@end

@implementation LoginViewController

#pragma mark - Custom Accessors
- (LoginViewModel *)loginViewModel
{
    if (!_loginViewModel) {
        _loginViewModel = [LoginViewModel new];
    }
    return _loginViewModel;
}

最后調(diào)用bindData方法進(jìn)行數(shù)據(jù)綁定

- (void)bindData
{
    RAC(self.loginViewModel, username) = self.rootView.usernameTextField.rac_textSignal;
    RAC(self.loginViewModel, password) = self.rootView.passwordTextField.rac_textSignal;
}

數(shù)據(jù)綁定測(cè)試

如果usernameTextField.text、passwordTextField.text與loginViewModel.username危队、loginViewModel.password已經(jīng)綁定數(shù)據(jù),那么usernameTextField.text和passwordTextField.text的數(shù)據(jù)變動(dòng)的話(huà)钙畔,一定會(huì)引起loginViewModel.username和loginViewModel.password的改變茫陆。那么測(cè)試用例可以這樣設(shè)計(jì):

數(shù)據(jù)綁定 Test Case

用kiwi編寫(xiě)測(cè)試如下:

SPEC_BEGIN(LoginViewControllerSpec)

describe(@"LoginViewController", ^{
    __block LoginViewController *controller = nil;

    beforeEach(^{
        controller = [LoginViewController new];
        [controller view];
    });

    afterEach(^{
        controller = nil;
    });

    describe(@"Root View", ^{
        __block LoginView *rootView = nil;

        beforeEach(^{
            rootView = controller.rootView;
        });

        context(@"when view did load", ^{
            it(@"should bind data", ^{
                rootView.usernameTextField.text = @"samlau";
                rootView.passwordTextField.text = @"freedom";

                [rootView.usernameTextField sendActionsForControlEvents:UIControlEventEditingChanged];
                [rootView.passwordTextField sendActionsForControlEvents:UIControlEventEditingChanged];

                [[controller.loginViewModel.username should] equal:rootView.usernameTextField.text];
                [[controller.loginViewModel.password should] equal:rootView.passwordTextField.text];
            });
        });

    });
});

SPEC_END

這個(gè)測(cè)試中有兩點(diǎn)需要重點(diǎn)解釋?zhuān)?/p>

  • 初始化完controller之后,controller一定要調(diào)用view方法來(lái)加載controller的view擎析,否則不會(huì)調(diào)用viewDidLoad方法簿盅。

如果有些朋友對(duì)controller如何管理view生命周期不了解,可以閱讀View Controller Programming Guide for iOS文檔中的A View Controller Instantiates Its View Hierarchy When Its View is Accessed章節(jié)

Loading a view into memory from Apple Document
  • usernameTextField和passwordTextField一定要調(diào)用sendActionsForControlEvents方法來(lái)通知UI已經(jīng)更新揍魂。
[rootView.usernameTextField sendActionsForControlEvents:UIControlEventEditingChanged];
[rootView.passwordTextField sendActionsForControlEvents:UIControlEventEditingChanged];

一開(kāi)始時(shí)桨醋,我并沒(méi)有調(diào)用sendActionsForControlEvents方法導(dǎo)致loginViewModel.usernameloginViewModel.password屬性并沒(méi)有更新。當(dāng)時(shí)我開(kāi)始思考现斋,是不是還需要其他條件還能觸發(fā)它更新呢喜最?由于我使用UITextFieldrac_textSignal屬性,于是我就查看它的源代碼:

- (RACSignal *)rac_textSignal {
   @weakify(self);
   return [[[[[RACSignal
       defer:^{
           @strongify(self);
           return [RACSignal return:self];
       }]
       concat:[self rac_signalForControlEvents:UIControlEventEditingChanged |  UIControlEventEditingDidBegin]]
       map:^(UITextField *x) {
           return x.text;
       }]
       takeUntil:self.rac_willDeallocSignal]
       setNameWithFormat:@"%@ -rac_textSignal", self.rac_description];
}

從源代碼可以知道庄蹋,只有觸發(fā)UIControlEventEditingChangedUIControlEventEditingDidBegin事件時(shí)才能創(chuàng)建RACSignal對(duì)象瞬内。

業(yè)務(wù)邏輯測(cè)試

由于這里需要驗(yàn)證用戶(hù)名和密碼,復(fù)用性高限书,我不將處理邏輯放在viewModel中虫蝶,而是定義一個(gè)DataValidation來(lái)處理。這里的用戶(hù)名是郵箱格式倦西,而密碼要求長(zhǎng)度大于等于6即可能真,方法如下:

@interface DataValidation : NSObject

+ (BOOL)isValidEmail:(NSString *)data;
+ (BOOL)isValidPassword:(NSString *)password;

@end

測(cè)試用例設(shè)計(jì)如下:

數(shù)據(jù)驗(yàn)證 Test Case.png

然后使用kiwi編寫(xiě)測(cè)試如下:

SPEC_BEGIN(DataValidationSpec)

describe(@"DataValidation", ^{
    context(@"when email is samlau@163.com", ^{
        it(@"should return YES", ^{
            BOOL result = [DataValidation isValidEmail:@"samlau@163.com"];
            [[theValue(result) should] beYes];
        });
    });
    
    context(@"when email is samlau163.com", ^{
        it(@"should return YES", ^{
            BOOL result = [DataValidation isValidEmail:@"samlau163.com"];
            [[theValue(result) should] beNo];
        });
    });
    
    ......省略?xún)蓚€(gè)測(cè)試用例
});

ViewModel層測(cè)試

前面已經(jīng)完成了數(shù)據(jù)綁定和數(shù)據(jù)校驗(yàn)邏輯,接下來(lái)思考使用哪個(gè)類(lèi)處理用戶(hù)名和密碼是否有效才能點(diǎn)擊和點(diǎn)擊按鈕后扰柠,如何調(diào)用網(wǎng)絡(luò)層在來(lái)匹配用戶(hù)名和密碼粉铐,RAC提供一個(gè)RACCommand類(lèi)。LoginViewModel定義一個(gè)屬性loginCommand耻矮,并在實(shí)現(xiàn)文件中使用Lazy Initialization初始化:

- (RACCommand *)loginCommand
{
    if (!_loginCommand) {
        _loginCommand = [[RACCommand alloc] initWithEnabled:[self isValidUsernameAndPasswordSignal] signalBlock:^RACSignal *(id input) {

            return [LoginClient loginWithUsername:self.username password:self.password];
        }];
    }
    return _loginCommand;
}

上面有一個(gè)重要方法isValidUsernameAndPasswordSignal來(lái)監(jiān)聽(tīng)和驗(yàn)證用戶(hù)名和密碼:

- (RACSignal *)isValidUsernameAndPasswordSignal
{
    return [RACSignal combineLatest:@[RACObserve(self, username), RACObserve(self, password)] reduce:^(NSString *username, NSString *password) {
         return @([DataValidation isValidEmail:username] && [DataValidation isValidPassword:password]);
    }];
}

由于上面的方法isValidUsernameAndPasswordSignal已經(jīng)監(jiān)聽(tīng)LoginViewModel的username和password秦躯,當(dāng)username和password其中一個(gè)改變時(shí),DataValidation類(lèi)都會(huì)調(diào)用isValidEmailisValidPassword來(lái)數(shù)據(jù)驗(yàn)證裆装,并將結(jié)果包裹成RACSignal對(duì)象返回踱承。

測(cè)試用例設(shè)計(jì)如下:

View Model Test Case

然后使用kiwi編寫(xiě)測(cè)試如下:

describe(@"LoginViewModel", ^{
    __block LoginViewModel* viewModel = nil;

    beforeEach(^{
        viewModel = [LoginViewModel new];
    });

    afterEach(^{
        viewModel = nil;
    });

    context(@"when username is samlau@163.com and password is freedom", ^{
        __block BOOL result = NO;

        it(@"should return signal that value is YES", ^{
            viewModel.username = @"samlau@163.com";
            viewModel.password = @"freedom";

            [[viewModel isValidUsernameAndPasswordSignal] subscribeNext:^(id x) {
                result = [x boolValue];
            }];

            [[theValue(result) should] beYes];
        });
    });

    ......省略?xún)蓚€(gè)測(cè)試用例
});

以上測(cè)試用例很簡(jiǎn)單倡缠,設(shè)置viewModel的username和password,然后調(diào)用isValidUsernameAndPasswordSignal返回RACSignal對(duì)象茎活,使用subscribeNext獲取它的值昙沦,最后驗(yàn)證。

網(wǎng)絡(luò)層測(cè)試

最后處理點(diǎn)擊登錄按鈕訪(fǎng)問(wèn)服務(wù)器來(lái)驗(yàn)證用戶(hù)名和密碼载荔。我定義一個(gè)LoginClient類(lèi)來(lái)處理:

@interface LoginClient : NSObject

+ (RACSignal *)loginWithUsername:(NSString *)username password:(NSString *)password;

@end

只要輸入username和password兩個(gè)參數(shù)盾饮,就能返回是否驗(yàn)證成功的結(jié)果被包裹在RACSignal對(duì)象中。

由于這里我是使用moco模擬服務(wù)懒熙,所以只設(shè)計(jì)一個(gè)成功的測(cè)試用例:

Network Test Case.png

然后使用kiwi編寫(xiě)測(cè)試如下:

describe(@"LoginClient", ^{
    context(@"when username is samlau@163.com and password is samlau", ^{
        __block BOOL success = NO;
        __block NSError *error = nil;
        
        it(@"should login successfully", ^{
            RACTuple *tuple = [[LoginClient loginWithUsername:@"samlau@163.com" password:@"samlau"] asynchronousFirstOrDefault:nil success:&success error:&error];
            NSDictionary *result = tuple.first;

            [[theValue(success) should] beYes];
            [[error should] beNil];
            [[result[@"result"] should] equal:@"success"];
        });
    });
});

里面使用RAC的一個(gè)重要方法asynchronousFirstOrDefault來(lái)測(cè)試異步網(wǎng)絡(luò)訪(fǎng)問(wèn)的丘损。詳情可參考Test with Reactivecocoa文章。

抓取網(wǎng)絡(luò)數(shù)據(jù)并顯示情景

如圖所示工扎,輸入正確的用戶(hù)名和密碼后徘钥,跳轉(zhuǎn)到一個(gè)食物列表頁(yè)面,它從服務(wù)端抓取圖片肢娘、價(jià)格和已售份數(shù)后以列表的方式顯示呈础。

網(wǎng)絡(luò)層測(cè)試

首先考慮如何設(shè)計(jì)和實(shí)現(xiàn)API,然后再考慮如何測(cè)試橱健。因?yàn)樗枰獜姆?wù)端抓取數(shù)據(jù)而钞,需要設(shè)計(jì)一個(gè)訪(fǎng)問(wèn)食物列表數(shù)據(jù)的類(lèi)FoodListClient,設(shè)計(jì)如下:

@interface FoodListClient : NSObject

+ (RACSignal *)fetchFoodList;

@end

FoodListClient實(shí)現(xiàn)如下:

@implementation FoodListClient

+ (RACSignal *)fetchFoodList
{
    return [[[AFHTTPSessionManager manager] rac_GET:[URLHelper URLWithResourcePath:@"/v1/foodlist"] parameters:nil] replayLazily];
}

@end

fetchFoodList方法主要從服務(wù)端抓取數(shù)據(jù)后拘荡,返回一個(gè)JSON格式的數(shù)組臼节。因此想測(cè)試這個(gè)API,只需要使用RAC的asynchronousFirstOrDefault方法返回RACTuple對(duì)象珊皿,獲取第一個(gè)值官疲,測(cè)試返回?cái)?shù)組不為空即可。使用kiwi編寫(xiě)測(cè)試如下:

describe(@"FoodListClient", ^{

    context(@"when fetch food list ", ^{
        __block BOOL successful = NO;
        __block NSError *error = nil;

        it(@"should receive data", ^{
            RACSignal *result = [FoodListClient fetchFoodList];
            RACTuple *tuple = [result asynchronousFirstOrDefault:nil success:&successful error:&error];
            NSArray *foodList = tuple.first;

            [[theValue(successful) should] beYes];
            [[error should] beNil];
            [[foodList shouldNot] beEmpty];
        });
    });
});

Model層測(cè)試

抓取完數(shù)據(jù)后亮隙,它的數(shù)據(jù)格式一般都是JSON格式途凫,需要轉(zhuǎn)化為Model方便訪(fǎng)問(wèn)和修改,通常我都使用Mantle來(lái)實(shí)現(xiàn)溢吻。我定義一個(gè)FoodModel類(lèi):

@interface FoodModel : MTLModel <MTLJSONSerializing>

/*
 @brief 食物圖片URL
 */
@property (copy, nonatomic) NSString *foodImageURL;
/*
 @brief 食物價(jià)格
 */
@property (copy, nonatomic) NSString *foodPrice;
/*
 @brief 銷(xiāo)量
 */
@property (copy, nonatomic) NSString *saleNumber;

@end

那么如何測(cè)試它是否轉(zhuǎn)化成功呢维费?首先基于上一個(gè)網(wǎng)絡(luò)層測(cè)試獲取返回JSON格式的食物列表數(shù)據(jù),然后調(diào)用MTLJSONAdapter類(lèi)的modelsOfClass: fromJSONArray: error:方法來(lái)轉(zhuǎn)化成FoodModel的數(shù)組促王。接下來(lái)斷言數(shù)組不能為空數(shù)組的第一個(gè)元素是FoodModel類(lèi)犀盟。

使用kiwi編寫(xiě)測(cè)試如下:

describe(@"FoodModel", ^{

    context(@"when JSON data convert to FoodModel", ^{
        __block BOOL successful = NO;
        __block NSError *error = nil;

        it(@"should return FoodModel array", ^{
            // get data from network
            RACSignal *result = [FoodListClient fetchFoodList];
            RACTuple *tuple = [result asynchronousFirstOrDefault:nil success:&successful error:&error];
            NSArray *foodList = tuple.first;

            // assert that foodList can't be empty
            [[theValue(successful) should] beYes];
            [[error should] beNil];
            [[foodList shouldNot] beEmpty];

            // assert that return FoolModel array
            NSArray *foodModelList = [MTLJSONAdapter modelsOfClass:[FoodModel class] fromJSONArray:foodList error:nil];
            [[foodModelList shouldNot] beEmpty];
            [[foodModelList[0] should] beKindOfClass:[FoodModel class]];
        });
    });
});

ViewModel抓取數(shù)據(jù)

完成抓取網(wǎng)絡(luò)數(shù)據(jù)和轉(zhuǎn)化JSON數(shù)據(jù)為Model后,我使用FoodViewModel來(lái)抓取網(wǎng)絡(luò)數(shù)據(jù)和完成數(shù)據(jù)映射蝇狼,設(shè)計(jì)與實(shí)現(xiàn)如下:

@interface FoodViewModel : RVMViewModel

/*
 @brief FoodModel列表
 */
@property (strong, nonatomic, readonly) NSArray *foodModelList;

@end
@implementation FoodViewModel

- (instancetype)init
{
    self = [super init];

    if (!self) {
        return nil;
    }

    RAC(self, foodModelList) = [[FoodListClient fetchFoodList] map:^id(RACTuple * tuple) {
        return [MTLJSONAdapter modelsOfClass:[FoodModel class] fromJSONArray:tuple.first error:nil];
    }];

    return self;
}

@end

Controller加載數(shù)據(jù)

最后FoodListViewController負(fù)責(zé)構(gòu)建view hierarchy和加載數(shù)據(jù):

#pragma mark - Lifecycle
- (void)viewDidLoad
{
    [super viewDidLoad];
    // setup title name and background color
    self.title = @"食物列表";
    self.view.backgroundColor = [UIColor whiteColor];
    // build view hierarchy
    [self buildViewHierarchy];
    // when finish fetching data and reload table view
    [RACObserve(self.foodViewModel, foodModelList) subscribeNext:^(NSArray* items) {
        self.foodListDataSource.items = items;
        [self.tableView reloadData];
    }];
}

總結(jié)

編寫(xiě)單元測(cè)試是程序員的一項(xiàng)基本技能阅畴,如果能夠設(shè)計(jì)好的測(cè)試用例并編寫(xiě)測(cè)試驗(yàn)證結(jié)果,不僅保證代碼的質(zhì)量迅耘,而且有利于以后重構(gòu)加一層保護(hù)層贱枣。一旦修改了代碼之后监署,如果運(yùn)行單元測(cè)試,并沒(méi)有通過(guò)的話(huà)纽哥,說(shuō)明你在重構(gòu)過(guò)程中引入新的bug钠乏。如果通過(guò)了單元測(cè)試,說(shuō)明并沒(méi)有引入新的bug春塌。

擴(kuò)展閱讀

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末晓避,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子只壳,更是在濱河造成了極大的恐慌俏拱,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,496評(píng)論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件吼句,死亡現(xiàn)場(chǎng)離奇詭異彰触,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)命辖,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,407評(píng)論 3 392
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)分蓖,“玉大人尔艇,你說(shuō)我怎么就攤上這事∶春祝” “怎么了终娃?”我有些...
    開(kāi)封第一講書(shū)人閱讀 162,632評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀(guān)的道長(zhǎng)蒸甜。 經(jīng)常有香客問(wèn)我棠耕,道長(zhǎng),這世上最難降的妖魔是什么柠新? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,180評(píng)論 1 292
  • 正文 為了忘掉前任窍荧,我火速辦了婚禮,結(jié)果婚禮上恨憎,老公的妹妹穿的比我還像新娘蕊退。我一直安慰自己,他們只是感情好憔恳,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,198評(píng)論 6 388
  • 文/花漫 我一把揭開(kāi)白布瓤荔。 她就那樣靜靜地躺著,像睡著了一般钥组。 火紅的嫁衣襯著肌膚如雪输硝。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 51,165評(píng)論 1 299
  • 那天程梦,我揣著相機(jī)與錄音点把,去河邊找鬼橘荠。 笑死,一個(gè)胖子當(dāng)著我的面吹牛愉粤,可吹牛的內(nèi)容都是我干的砾医。 我是一名探鬼主播,決...
    沈念sama閱讀 40,052評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼衣厘,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼如蚜!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起影暴,我...
    開(kāi)封第一講書(shū)人閱讀 38,910評(píng)論 0 274
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤错邦,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后型宙,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體撬呢,經(jīng)...
    沈念sama閱讀 45,324評(píng)論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,542評(píng)論 2 332
  • 正文 我和宋清朗相戀三年妆兑,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了魂拦。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 39,711評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡搁嗓,死狀恐怖芯勘,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情腺逛,我是刑警寧澤荷愕,帶...
    沈念sama閱讀 35,424評(píng)論 5 343
  • 正文 年R本政府宣布,位于F島的核電站棍矛,受9級(jí)特大地震影響安疗,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜够委,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,017評(píng)論 3 326
  • 文/蒙蒙 一荐类、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧茁帽,春花似錦掉冶、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 31,668評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至战秋,卻和暖如春璧亚,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 32,823評(píng)論 1 269
  • 我被黑心中介騙來(lái)泰國(guó)打工癣蟋, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留透硝,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 47,722評(píng)論 2 368
  • 正文 我出身青樓疯搅,卻偏偏與公主長(zhǎng)得像濒生,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子幔欧,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,611評(píng)論 2 353

推薦閱讀更多精彩內(nèi)容